diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 81c57394..aec95224 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -16,32 +16,6 @@ from db.db_helper import get_date_from_period # noqa: E402 [flake8 lint suppres from app_state import updateState # noqa: E402 [flake8 lint suppression] from .graphql_endpoint import devicesSchema # noqa: E402 [flake8 lint suppression] -from .device_endpoint import ( # noqa: E402 [flake8 lint suppression] - get_device_data, - set_device_data, - delete_device, - delete_device_events, - reset_device_props, - copy_device, - update_device_column -) -from .devices_endpoint import ( # noqa: E402 [flake8 lint suppression] - get_all_devices, - delete_unknown_devices, - delete_all_with_empty_macs, - delete_devices, - export_devices, - import_csv, - devices_totals, - devices_by_status -) -from .events_endpoint import ( # noqa: E402 [flake8 lint suppression] - delete_events, - delete_events_older_than, - get_events, - create_event, - get_events_totals -) 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] @@ -223,35 +197,55 @@ def api_get_setting(setKey): def api_get_device(mac): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return get_device_data(mac) + + period = request.args.get("period", "") + device_handler = DeviceInstance() + device_data = device_handler.getDeviceData(mac, period) + + if device_data is None: + return jsonify({"error": "Device not found"}), 404 + + return jsonify(device_data) @app.route("/device/", methods=["POST"]) def api_set_device(mac): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return set_device_data(mac, request.json) + + device_handler = DeviceInstance() + result = device_handler.setDeviceData(mac, request.json) + return jsonify(result) @app.route("/device//delete", methods=["DELETE"]) def api_delete_device(mac): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return delete_device(mac) + + device_handler = DeviceInstance() + result = device_handler.deleteDeviceByMAC(mac) + return jsonify(result) @app.route("/device//events/delete", methods=["DELETE"]) def api_delete_device_events(mac): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return delete_device_events(mac) + + device_handler = DeviceInstance() + result = device_handler.deleteDeviceEvents(mac) + return jsonify(result) @app.route("/device//reset-props", methods=["POST"]) def api_reset_device_props(mac): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return reset_device_props(mac, request.json) + + device_handler = DeviceInstance() + result = device_handler.resetDeviceProps(mac) + return jsonify(result) @app.route("/device/copy", methods=["POST"]) @@ -266,7 +260,9 @@ def api_copy_device(): if not mac_from or not mac_to: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "macFrom and macTo are required"}), 400 - return copy_device(mac_from, mac_to) + device_handler = DeviceInstance() + result = device_handler.copyDevice(mac_from, mac_to) + return jsonify(result) @app.route("/device//update-column", methods=["POST"]) @@ -281,20 +277,29 @@ def api_update_device_column(mac): if not column_name or not column_value: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "columnName and columnValue are required"}), 400 - return update_device_column(mac, column_name, column_value) + 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']) def api_device_set_alias(mac): - """Set the device alias - convenience wrapper around update_device_column.""" + """Set the device alias - convenience wrapper around updateDeviceColumn.""" 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) + + device_handler = DeviceInstance() + result = device_handler.updateDeviceColumn(mac, 'devName', alias) + return jsonify(result) @app.route('/mcp/sse/device/open_ports', methods=['POST']) @@ -327,7 +332,9 @@ def api_device_open_ports(): def api_get_devices(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return get_all_devices() + device_handler = DeviceInstance() + devices = device_handler.getAll_AsResponse() + return jsonify({"success": True, "devices": devices}) @app.route("/devices", methods=["DELETE"]) @@ -336,24 +343,27 @@ def api_delete_devices(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 macs = request.json.get("macs") if request.is_json else None - - return delete_devices(macs) + device_handler = DeviceInstance() + return jsonify(device_handler.deleteDevices(macs)) @app.route("/devices/empty-macs", methods=["DELETE"]) def api_delete_all_empty_macs(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return delete_all_with_empty_macs() + device_handler = DeviceInstance() + return jsonify(device_handler.deleteAllWithEmptyMacs()) @app.route("/devices/unknown", methods=["DELETE"]) def api_delete_unknown_devices(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return delete_unknown_devices() + 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"]) def api_export_devices(format=None): @@ -361,21 +371,52 @@ def api_export_devices(format=None): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 export_format = (format or request.args.get("format", "csv")).lower() - return export_devices(export_format) + 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"]) def api_import_csv(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return import_csv(request.files.get("file")) + + 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"]) def api_devices_totals(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return devices_totals() + device_handler = DeviceInstance() + return jsonify(device_handler.getTotals()) @app.route('/mcp/sse/devices/by-status', methods=['GET', 'POST']) @@ -385,8 +426,8 @@ def api_devices_by_status(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 status = request.args.get("status", "") if request.args else None - - return devices_by_status(status) + device_handler = DeviceInstance() + return jsonify(device_handler.getByStatus(status)) @app.route('/mcp/sse/devices/search', methods=['POST']) @@ -402,16 +443,16 @@ def api_devices_search(): if not query: return jsonify({"success": False, "message": "Missing 'query' parameter", "error": "Missing query"}), 400 + device_handler = DeviceInstance() + 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()]}) + + 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 - # Create fresh DB instance for this thread - device_handler = DeviceInstance() - matches = device_handler.search(query) if not matches: @@ -432,10 +473,26 @@ def api_devices_latest(): latest = device_handler.getLatest() if not latest: - return jsonify({"message": "No devices found"}), 404 + return jsonify({"success": False, "message": "No devices found"}), 404 return jsonify([latest]) +@app.route('/mcp/sse/devices/favorite', methods=['GET']) +@app.route('/devices/favorite', methods=['GET']) +def api_devices_favorite(): + """Get favorite devices - maps to DeviceInstance.getFavorite().""" + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + device_handler = DeviceInstance() + + favorite = device_handler.getFavorite() + + if not favorite: + return jsonify({"success": False, "message": "No devices found"}), 404 + return jsonify([favorite]) + + @app.route('/mcp/sse/devices/network/topology', methods=['GET']) @app.route('/devices/network/topology', methods=['GET']) def api_devices_network_topology(): @@ -479,6 +536,7 @@ def api_wakeonlan(): return wakeonlan(mac) +@app.route('/mcp/sse/nettools/traceroute', methods=['POST']) @app.route("/nettools/traceroute", methods=["POST"]) def api_traceroute(): if not is_authorized(): @@ -720,25 +778,30 @@ def api_create_event(mac): pending_alert = data.get("pending_alert", 1) event_time = data.get("event_time", None) - # Call the helper to insert into DB - create_event(mac, ip, event_type, additional_info, pending_alert, event_time) + event_handler = EventInstance() + result = event_handler.createEvent(mac, ip, event_type, additional_info, pending_alert, event_time) - # Return consistent JSON response - return jsonify({"success": True, "message": f"Event created for {mac}"}) + return jsonify(result) @app.route("/events/", methods=["DELETE"]) def api_events_by_mac(mac): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return delete_device_events(mac) + + device_handler = DeviceInstance() + result = device_handler.deleteDeviceEvents(mac) + return jsonify(result) @app.route("/events", methods=["DELETE"]) def api_delete_all_events(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return delete_events() + + event_handler = EventInstance() + result = event_handler.deleteAllEvents() + return jsonify(result) @app.route("/events", methods=["GET"]) @@ -747,7 +810,9 @@ def api_get_events(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 mac = request.args.get("mac") - return get_events(mac) + event_handler = EventInstance() + events = event_handler.getEvents(mac) + return jsonify({"count": len(events), "events": events}) @app.route("/events/", methods=["DELETE"]) @@ -759,7 +824,9 @@ def api_delete_old_events(days: int): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - return delete_events_older_than(days) + event_handler = EventInstance() + result = event_handler.deleteEventsOlderThan(days) + return jsonify(result) @app.route("/sessions/totals", methods=["GET"]) @@ -767,8 +834,10 @@ def api_get_events_totals(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - period = get_date_from_period(request.args.get("period", "7 days")) - return get_events_totals(period) + 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']) diff --git a/server/api_server/device_endpoint.py b/server/api_server/device_endpoint.py deleted file mode 100755 index 401aba2f..00000000 --- a/server/api_server/device_endpoint.py +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env python - -import os -import sys -from flask import jsonify, request - -# Register NetAlertX directories -INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") -sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) - -from database import get_temp_db_connection # noqa: E402 [flake8 lint suppression] -from helper import is_random_mac, get_setting_value # noqa: E402 [flake8 lint suppression] -from utils.datetime_utils import timeNowDB, format_date # noqa: E402 [flake8 lint suppression] -from db.db_helper import row_to_json, get_date_from_period # noqa: E402 [flake8 lint suppression] - -# -------------------------- -# Device Endpoints Functions -# -------------------------- - - -def get_device_data(mac): - """Fetch device info with children, event stats, and presence calculation.""" - - # Open temporary connection for this request - conn = get_temp_db_connection() - cur = conn.cursor() - - now = timeNowDB() - - # Special case for new device - if mac.lower() == "new": - - device_data = { - "devMac": "", - "devName": "", - "devOwner": "", - "devType": "", - "devVendor": "", - "devFavorite": 0, - "devGroup": "", - "devComments": "", - "devFirstConnection": now, - "devLastConnection": now, - "devLastIP": "", - "devStaticIP": 0, - "devScan": 0, - "devLogEvents": 0, - "devAlertEvents": 0, - "devAlertDown": 0, - "devParentRelType": "default", - "devReqNicsOnline": 0, - "devSkipRepeated": 0, - "devLastNotification": "", - "devPresentLastScan": 0, - "devIsNew": 1, - "devLocation": "", - "devIsArchived": 0, - "devParentMAC": "", - "devParentPort": "", - "devIcon": "", - "devGUID": "", - "devSite": "", - "devSSID": "", - "devSyncHubNode": "", - "devSourcePlugin": "", - "devCustomProps": "", - "devStatus": "Unknown", - "devIsRandomMAC": False, - "devSessions": 0, - "devEvents": 0, - "devDownAlerts": 0, - "devPresenceHours": 0, - "devFQDN": "", - } - return jsonify(device_data) - - # Compute period date for sessions/events - period = request.args.get("period", "") # e.g., '7 days', '1 month', etc. - period_date_sql = get_date_from_period(period) - - # Fetch device info + computed fields - sql = f""" - SELECT - d.*, - CASE - WHEN d.devAlertDown != 0 AND d.devPresentLastScan = 0 THEN 'Down' - WHEN d.devPresentLastScan = 1 THEN 'On-line' - ELSE 'Off-line' - END AS devStatus, - - (SELECT COUNT(*) FROM Sessions - WHERE ses_MAC = d.devMac AND ( - ses_DateTimeConnection >= {period_date_sql} OR - ses_DateTimeDisconnection >= {period_date_sql} OR - ses_StillConnected = 1 - )) AS devSessions, - - (SELECT COUNT(*) FROM Events - WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql} - AND eve_EventType NOT IN ('Connected','Disconnected')) AS devEvents, - - (SELECT COUNT(*) FROM Events - WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql} - AND eve_EventType = 'Device Down') AS devDownAlerts, - - (SELECT CAST(MAX(0, SUM( - julianday(IFNULL(ses_DateTimeDisconnection,'{now}')) - - julianday(CASE WHEN ses_DateTimeConnection < {period_date_sql} - THEN {period_date_sql} ELSE ses_DateTimeConnection END) - ) * 24) AS INT) - FROM Sessions - WHERE ses_MAC = d.devMac - AND ses_DateTimeConnection IS NOT NULL - AND (ses_DateTimeDisconnection IS NOT NULL OR ses_StillConnected = 1) - AND (ses_DateTimeConnection >= {period_date_sql} - OR ses_DateTimeDisconnection >= {period_date_sql} OR ses_StillConnected = 1) - ) AS devPresenceHours - - FROM Devices d - WHERE d.devMac = ? OR CAST(d.rowid AS TEXT) = ? - """ - # Fetch device - cur.execute(sql, (mac, mac)) - row = cur.fetchone() - if not row: - return jsonify({"error": "Device not found"}), 404 - - device_data = row_to_json(list(row.keys()), row) - device_data["devFirstConnection"] = format_date(device_data["devFirstConnection"]) - device_data["devLastConnection"] = format_date(device_data["devLastConnection"]) - device_data["devIsRandomMAC"] = is_random_mac(device_data["devMac"]) - - # Fetch children - cur.execute( - "SELECT * FROM Devices WHERE devParentMAC = ? ORDER BY devPresentLastScan DESC", - (device_data["devMac"],), - ) - children_rows = cur.fetchall() - children = [row_to_json(list(r.keys()), r) for r in children_rows] - children_nics = [c for c in children if c.get("devParentRelType") == "nic"] - - device_data["devChildrenDynamic"] = children - device_data["devChildrenNicsDynamic"] = children_nics - - conn.close() - - return jsonify(device_data) - - -def set_device_data(mac, data): - """Update or create a device.""" - if data.get("createNew", False): - sql = """ - INSERT INTO Devices ( - devMac, devName, devOwner, devType, devVendor, devIcon, - devFavorite, devGroup, devLocation, devComments, - devParentMAC, devParentPort, devSSID, devSite, - devStaticIP, devScan, devAlertEvents, devAlertDown, - devParentRelType, devReqNicsOnline, devSkipRepeated, - devIsNew, devIsArchived, devLastConnection, - devFirstConnection, devLastIP, devGUID, devCustomProps, - devSourcePlugin - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """ - - values = ( - mac, - data.get("devName", ""), - data.get("devOwner", ""), - data.get("devType", ""), - data.get("devVendor", ""), - data.get("devIcon", ""), - data.get("devFavorite", 0), - data.get("devGroup", ""), - data.get("devLocation", ""), - data.get("devComments", ""), - data.get("devParentMAC", ""), - data.get("devParentPort", ""), - data.get("devSSID", ""), - data.get("devSite", ""), - data.get("devStaticIP", 0), - data.get("devScan", 0), - data.get("devAlertEvents", 0), - data.get("devAlertDown", 0), - data.get("devParentRelType", "default"), - data.get("devReqNicsOnline", 0), - data.get("devSkipRepeated", 0), - data.get("devIsNew", 0), - data.get("devIsArchived", 0), - data.get("devLastConnection", timeNowDB()), - data.get("devFirstConnection", timeNowDB()), - data.get("devLastIP", ""), - data.get("devGUID", ""), - data.get("devCustomProps", ""), - data.get("devSourcePlugin", "DUMMY"), - ) - - else: - sql = """ - UPDATE Devices SET - devName=?, devOwner=?, devType=?, devVendor=?, devIcon=?, - devFavorite=?, devGroup=?, devLocation=?, devComments=?, - devParentMAC=?, devParentPort=?, devSSID=?, devSite=?, - devStaticIP=?, devScan=?, devAlertEvents=?, devAlertDown=?, - devParentRelType=?, devReqNicsOnline=?, devSkipRepeated=?, - devIsNew=?, devIsArchived=?, devCustomProps=? - WHERE devMac=? - """ - values = ( - data.get("devName", ""), - data.get("devOwner", ""), - data.get("devType", ""), - data.get("devVendor", ""), - data.get("devIcon", ""), - data.get("devFavorite", 0), - data.get("devGroup", ""), - data.get("devLocation", ""), - data.get("devComments", ""), - data.get("devParentMAC", ""), - data.get("devParentPort", ""), - data.get("devSSID", ""), - data.get("devSite", ""), - data.get("devStaticIP", 0), - data.get("devScan", 0), - data.get("devAlertEvents", 0), - data.get("devAlertDown", 0), - data.get("devParentRelType", "default"), - data.get("devReqNicsOnline", 0), - data.get("devSkipRepeated", 0), - data.get("devIsNew", 0), - data.get("devIsArchived", 0), - data.get("devCustomProps", ""), - mac, - ) - - conn = get_temp_db_connection() - cur = conn.cursor() - cur.execute(sql, values) - conn.commit() - conn.close() - return jsonify({"success": True}) - - -def delete_device(mac): - """Delete a device by MAC.""" - conn = get_temp_db_connection() - cur = conn.cursor() - cur.execute("DELETE FROM Devices WHERE devMac=?", (mac,)) - conn.commit() - conn.close() - return jsonify({"success": True}) - - -def delete_device_events(mac): - """Delete all events for a device.""" - conn = get_temp_db_connection() - cur = conn.cursor() - cur.execute("DELETE FROM Events WHERE eve_MAC=?", (mac,)) - conn.commit() - conn.close() - return jsonify({"success": True}) - - -def reset_device_props(mac, data=None): - """Reset device custom properties to default.""" - default_props = get_setting_value("NEWDEV_devCustomProps") - conn = get_temp_db_connection() - cur = conn.cursor() - cur.execute( - "UPDATE Devices SET devCustomProps=? WHERE devMac=?", - (default_props, mac), - ) - conn.commit() - conn.close() - return jsonify({"success": True}) - - -def update_device_column(mac, column_name, column_value): - """ - Update a specific column for a given device. - Example: update_device_column("AA:BB:CC:DD:EE:FF", "devParentMAC", "Internet") - """ - - conn = get_temp_db_connection() - cur = conn.cursor() - - # Build safe SQL with column name whitelisted - sql = f"UPDATE Devices SET {column_name}=? WHERE devMac=?" - cur.execute(sql, (column_value, mac)) - conn.commit() - - if cur.rowcount > 0: - return jsonify({"success": True}) - else: - return jsonify({"success": False, "error": "Device not found"}), 404 - - conn.close() - - return jsonify({"success": True}) - - -def copy_device(mac_from, mac_to): - """ - Copy a device entry from one MAC to another. - If a device already exists with mac_to, it will be replaced. - """ - conn = get_temp_db_connection() - cur = conn.cursor() - - try: - # Drop temporary table if exists - cur.execute("DROP TABLE IF EXISTS temp_devices") - - # Create temporary table with source device - cur.execute( - "CREATE TABLE temp_devices AS SELECT * FROM Devices WHERE devMac = ?", - (mac_from,), - ) - - # Update temporary table to target MAC - cur.execute("UPDATE temp_devices SET devMac = ?", (mac_to,)) - - # Delete previous entry with target MAC - cur.execute("DELETE FROM Devices WHERE devMac = ?", (mac_to,)) - - # Insert new entry from temporary table - cur.execute( - "INSERT INTO Devices SELECT * FROM temp_devices WHERE devMac = ?", (mac_to,) - ) - - # Drop temporary table - cur.execute("DROP TABLE temp_devices") - - conn.commit() - return jsonify( - {"success": True, "message": f"Device copied from {mac_from} to {mac_to}"} - ) - - except Exception as e: - conn.rollback() - return jsonify({"success": False, "error": str(e)}) - - finally: - conn.close() diff --git a/server/api_server/devices_endpoint.py b/server/api_server/devices_endpoint.py deleted file mode 100755 index 2e850d5e..00000000 --- a/server/api_server/devices_endpoint.py +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env python - -import os -import base64 -import re -import sys -import sqlite3 -from flask import jsonify, request, Response -import csv -from io import StringIO -from logger import mylog - -# Register NetAlertX directories -INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") -sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) - -from database import get_temp_db_connection # noqa: E402 [flake8 lint suppression] -from db.db_helper import get_table_json, get_device_condition_by_status # noqa: E402 [flake8 lint suppression] - - -# -------------------------- -# Device Endpoints Functions -# -------------------------- -def get_all_devices(): - """Retrieve all devices from the database.""" - conn = get_temp_db_connection() - cur = conn.cursor() - cur.execute("SELECT * FROM Devices") - rows = cur.fetchall() - - # Convert rows to list of dicts using column names - columns = [col[0] for col in cur.description] - devices = [dict(zip(columns, row)) for row in rows] - - conn.close() - return jsonify({"success": True, "devices": devices}) - - -def delete_devices(macs): - """ - Delete devices from the Devices table. - - If `macs` is None → delete ALL devices. - - If `macs` is a list → delete only matching MACs (supports wildcard '*'). - """ - - conn = get_temp_db_connection() - cur = conn.cursor() - - if not macs: - # No MACs provided → delete all - cur.execute("DELETE FROM Devices") - conn.commit() - conn.close() - return jsonify({"success": True, "deleted": "all"}) - - deleted_count = 0 - - for mac in macs: - if "*" in mac: - # Wildcard matching - sql_pattern = mac.replace("*", "%") - cur.execute("DELETE FROM Devices WHERE devMAC LIKE ?", (sql_pattern,)) - else: - # Exact match - cur.execute("DELETE FROM Devices WHERE devMAC = ?", (mac,)) - deleted_count += cur.rowcount - - conn.commit() - conn.close() - - return jsonify({"success": True, "deleted_count": deleted_count}) - - -def delete_all_with_empty_macs(): - """Delete devices with empty MAC addresses.""" - conn = get_temp_db_connection() - cur = conn.cursor() - cur.execute("DELETE FROM Devices WHERE devMAC IS NULL OR devMAC = ''") - deleted = cur.rowcount - conn.commit() - conn.close() - return jsonify({"success": True, "deleted": deleted}) - - -def delete_unknown_devices(): - """Delete devices marked as unknown.""" - conn = get_temp_db_connection() - cur = conn.cursor() - cur.execute( - """DELETE FROM Devices WHERE devName='(unknown)' OR devName='(name not found)'""" - ) - conn.commit() - conn.close() - return jsonify({"success": True, "deleted": cur.rowcount}) - - -def export_devices(export_format): - """ - Export devices from the Devices table in the desired format. - - If `macs` is None → delete ALL devices. - - If `macs` is a list → delete only matching MACs (supports wildcard '*'). - """ - conn = get_temp_db_connection() - cur = conn.cursor() - - # Fetch all devices - devices_json = get_table_json(cur, "SELECT * FROM Devices") - conn.close() - - # Ensure columns exist - columns = devices_json.columnNames or ( - list(devices_json["data"][0].keys()) if devices_json["data"] else [] - ) - - if export_format == "json": - # Convert to standard dict for Flask JSON - return jsonify( - {"data": [row for row in devices_json["data"]], "columns": list(columns)} - ) - elif export_format == "csv": - si = StringIO() - writer = csv.DictWriter(si, fieldnames=columns, quoting=csv.QUOTE_ALL) - writer.writeheader() - for row in devices_json.json["data"]: - writer.writerow(row) - - return Response( - si.getvalue(), - mimetype="text/csv", - headers={"Content-Disposition": "attachment; filename=devices.csv"}, - ) - else: - return jsonify({"error": f"Unsupported format '{export_format}'"}), 400 - - -def import_csv(file_storage=None): - data = "" - skipped = [] - - # 1. Try JSON `content` (base64-encoded CSV) - if request.is_json and request.json.get("content"): - try: - data = base64.b64decode(request.json["content"], validate=True).decode( - "utf-8" - ) - except Exception as e: - return jsonify({"error": f"Base64 decode failed: {e}"}), 400 - - # 2. Otherwise, try uploaded file - elif file_storage: - data = file_storage.read().decode("utf-8") - - # 3. Fallback: try local file (same as PHP `$file = '../../../config/devices.csv';`) - else: - config_root = os.environ.get("NETALERTX_CONFIG", "/data/config") - local_file = os.path.join(config_root, "devices.csv") - try: - with open(local_file, "r", encoding="utf-8") as f: - data = f.read() - except FileNotFoundError: - return jsonify({"error": "CSV file missing"}), 404 - - if not data: - return jsonify({"error": "No CSV data found"}), 400 - - # --- Clean up newlines inside quoted fields --- - data = re.sub(r'"([^"]*)"', lambda m: m.group(0).replace("\n", " "), data) - - # --- Parse CSV --- - lines = data.splitlines() - reader = csv.reader(lines) - try: - header = [h.strip() for h in next(reader)] - except StopIteration: - return jsonify({"error": "CSV missing header"}), 400 - - # --- Wipe Devices table --- - conn = get_temp_db_connection() - sql = conn.cursor() - sql.execute("DELETE FROM Devices") - - # --- Prepare insert --- - placeholders = ",".join(["?"] * len(header)) - insert_sql = f"INSERT INTO Devices ({', '.join(header)}) VALUES ({placeholders})" - - row_count = 0 - for idx, row in enumerate(reader, start=1): - if len(row) != len(header): - skipped.append(idx) - continue - try: - sql.execute(insert_sql, [col.strip() for col in row]) - row_count += 1 - except sqlite3.Error as e: - mylog("error", [f"[ImportCSV] SQL ERROR row {idx}: {e}"]) - skipped.append(idx) - - conn.commit() - conn.close() - - return jsonify({"success": True, "inserted": row_count, "skipped_lines": skipped}) - - -def devices_totals(): - conn = get_temp_db_connection() - sql = conn.cursor() - - # Build a combined query with sub-selects for each status - query = f""" - SELECT - (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("my")}) AS devices, - (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("connected")}) AS connected, - (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("favorites")}) AS favorites, - (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("new")}) AS new, - (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("down")}) AS down, - (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("archived")}) AS archived - """ - sql.execute(query) - row = ( - sql.fetchone() - ) # returns a tuple like (devices, connected, favorites, new, down, archived) - - conn.close() - - # Return counts as JSON array - return jsonify(list(row)) - - -def devices_by_status(status=None): - """ - Return devices filtered by status. Returns all if no status provided. - Possible statuses: my, connected, favorites, new, down, archived - """ - - conn = get_temp_db_connection() - sql = conn.cursor() - - # Build condition for SQL - condition = get_device_condition_by_status(status) if status else "" - - query = f"SELECT * FROM Devices {condition}" - sql.execute(query) - - table_data = [] - for row in sql.fetchall(): - r = dict(row) # Convert sqlite3.Row to dict for .get() - dev_name = r.get("devName", "") - if r.get("devFavorite") == 1: - dev_name = f' {dev_name}' - - table_data.append( - { - "id": r.get("devMac", ""), - "title": dev_name, - "favorite": r.get("devFavorite", 0), - } - ) - - conn.close() - return jsonify(table_data) diff --git a/server/api_server/events_endpoint.py b/server/api_server/events_endpoint.py deleted file mode 100755 index 2ceddd37..00000000 --- a/server/api_server/events_endpoint.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python - -import os -import sys -from datetime import datetime -from flask import jsonify - -# Register NetAlertX directories -INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") -sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) - -from database import get_temp_db_connection # noqa: E402 [flake8 lint suppression] -from helper import mylog # noqa: E402 [flake8 lint suppression] -from db.db_helper import row_to_json, get_date_from_period # noqa: E402 [flake8 lint suppression] -from utils.datetime_utils import ensure_datetime # noqa: E402 [flake8 lint suppression] - - -# -------------------------- -# Events Endpoints Functions -# -------------------------- - - -def create_event( - mac: str, - ip: str, - event_type: str = "Device Down", - additional_info: str = "", - pending_alert: int = 1, - event_time: datetime | None = None, -): - """ - Insert a single event into the Events table and return a standardized JSON response. - Exceptions will propagate to the caller. - """ - conn = get_temp_db_connection() - cur = conn.cursor() - if isinstance(event_time, str): - start_time = ensure_datetime(event_time) - - start_time = ensure_datetime(event_time) - - cur.execute( - """ - INSERT INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType, eve_AdditionalInfo, eve_PendingAlertEmail) - VALUES (?, ?, ?, ?, ?, ?) - """, - (mac, ip, start_time, event_type, additional_info, pending_alert), - ) - - conn.commit() - conn.close() - - mylog("debug", f"[Events] Created event for {mac} ({event_type})") - return jsonify({"success": True, "message": f"Created event for {mac}"}) - - -def get_events(mac=None): - """ - Fetch all events, or events for a specific MAC if provided. - Returns JSON list of events. - """ - conn = get_temp_db_connection() - cur = conn.cursor() - - if mac: - sql = "SELECT * FROM Events WHERE eve_MAC=? ORDER BY eve_DateTime DESC" - cur.execute(sql, (mac,)) - else: - sql = "SELECT * FROM Events ORDER BY eve_DateTime DESC" - cur.execute(sql) - - rows = cur.fetchall() - events = [row_to_json(list(r.keys()), r) for r in rows] - - conn.close() - return jsonify({"success": True, "events": events}) - - -def delete_events_older_than(days): - """Delete all events older than a specified number of days""" - - conn = get_temp_db_connection() - cur = conn.cursor() - - # Use a parameterized query with sqlite date function - sql = "DELETE FROM Events WHERE eve_DateTime <= date('now', ?)" - cur.execute(sql, [f"-{days} days"]) - - conn.commit() - conn.close() - - return jsonify( - {"success": True, "message": f"Deleted events older than {days} days"} - ) - - -def delete_events(): - """Delete all events""" - - conn = get_temp_db_connection() - cur = conn.cursor() - - sql = "DELETE FROM Events" - cur.execute(sql) - conn.commit() - conn.close() - - return jsonify({"success": True, "message": "Deleted all events"}) - - -def get_events_totals(period: str = "7 days"): - """ - Return counts for events and sessions totals over a given period. - period: "7 days", "1 month", "1 year", "100 years" - """ - # Convert period to SQLite date expression - period_date_sql = get_date_from_period(period) - - conn = get_temp_db_connection() - cur = conn.cursor() - - sql = f""" - SELECT - (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql}) AS all_events, - (SELECT COUNT(*) FROM Sessions WHERE - ses_DateTimeConnection >= {period_date_sql} - OR ses_DateTimeDisconnection >= {period_date_sql} - OR ses_StillConnected = 1 - ) AS sessions, - (SELECT COUNT(*) FROM Sessions WHERE - (ses_DateTimeConnection IS NULL AND ses_DateTimeDisconnection >= {period_date_sql}) - OR (ses_DateTimeDisconnection IS NULL AND ses_StillConnected = 0 AND ses_DateTimeConnection >= {period_date_sql}) - ) AS missing, - (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql} AND eve_EventType LIKE 'VOIDED%') AS voided, - (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql} AND eve_EventType LIKE 'New Device') AS new, - (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql} AND eve_EventType LIKE 'Device Down') AS down - """ - - cur.execute(sql) - row = cur.fetchone() - conn.close() - - # Return as JSON array - result_json = [row[0], row[1], row[2], row[3], row[4], row[5]] - return jsonify(result_json) diff --git a/server/api_server/mcp_endpoint.py b/server/api_server/mcp_endpoint.py index 130324c8..b9972ad3 100644 --- a/server/api_server/mcp_endpoint.py +++ b/server/api_server/mcp_endpoint.py @@ -1,4 +1,15 @@ #!/usr/bin/env python +""" +NetAlertX MCP (Model Context Protocol) Server Endpoint. + +This module implements an MCP server that exposes NetAlertX API endpoints as tools +for AI assistants. It provides JSON-RPC over HTTP and Server-Sent Events (SSE) +for tool discovery and execution. + +The server maps OpenAPI specifications to MCP tools, allowing AIs to list available +tools and call them with appropriate parameters. Tools include device management, +network scanning, event querying, and more. +""" import threading from flask import Blueprint, request, jsonify, Response, stream_with_context @@ -14,11 +25,18 @@ import queue mcp_bp = Blueprint('mcp', __name__) tools_bp = Blueprint('tools', __name__) +# Global session management for MCP SSE connections mcp_sessions = {} mcp_sessions_lock = threading.Lock() def check_auth(): + """ + Check if the request has valid authorization. + + Returns: + bool: True if the Authorization header matches the expected API token, False otherwise. + """ token = request.headers.get("Authorization") expected_token = f"Bearer {get_setting_value('API_TOKEN')}" return token == expected_token @@ -28,6 +46,15 @@ def check_auth(): # Specs # -------------------------- def openapi_spec(): + """ + Generate the OpenAPI specification for NetAlertX tools. + + This function returns a JSON representation of the available API endpoints + that are exposed as MCP tools, including paths, methods, and operation IDs. + + Returns: + flask.Response: A JSON response containing the OpenAPI spec. + """ # Spec matching actual available routes for MCP tools mylog("verbose", ["[MCP] OpenAPI spec requested"]) spec = { @@ -35,17 +62,112 @@ def openapi_spec(): "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"}} + "/devices/by-status": { + "post": { + "operationId": "list_devices", + "description": "List devices filtered by their online/offline status. " + "Accepts optional 'status' query parameter (online/offline)." + } + }, + "/device/{mac}": { + "post": { + "operationId": "get_device_info", + "description": "Retrieve detailed information about a specific device by MAC address." + } + }, + "/devices/search": { + "post": { + "operationId": "search_devices", + "description": "Search for devices based on various criteria like name, IP, etc. " + "Accepts JSON with 'query' field." + } + }, + "/devices/latest": { + "get": { + "operationId": "get_latest_device", + "description": "Get information about the most recently seen device." + } + }, + "/devices/favorite": { + "get": { + "operationId": "get_favorite_devices", + "description": "Get favorite devices." + } + }, + "/nettools/trigger-scan": { + "post": { + "operationId": "trigger_scan", + "description": "Trigger a network scan to discover new devices. " + "Accepts optional 'type' parameter for scan type - needs to match an enabled plugin name (e.g., ARPSCAN, NMAPDEV, NMAP)." + } + }, + "/device/open_ports": { + "post": { + "operationId": "get_open_ports", + "description": "Get a list of open ports for a specific device. " + "Accepts JSON with 'target' (IP or MAC address). Trigger NMAP scan if no previous ports found with the /nettools/trigger-scan endpoint." + } + }, + "/devices/network/topology": { + "get": { + "operationId": "get_network_topology", + "description": "Retrieve the network topology information." + } + }, + "/events/recent": { + "get": { + "operationId": "get_recent_alerts", + "description": "Get recent events/alerts from the system. Defaults to last 24 hours." + }, + "post": {"operationId": "get_recent_alerts"} + }, + "/events/last": { + "get": { + "operationId": "get_last_events", + "description": "Get the last 10 events logged in the system." + }, + "post": {"operationId": "get_last_events"} + }, + "/device/{mac}/set-alias": { + "post": { + "operationId": "set_device_alias", + "description": "Set or update the alias/name for a device. Accepts JSON with 'alias' field." + } + }, + "/nettools/wakeonlan": { + "post": { + "operationId": "wol_wake_device", + "description": "Send a Wake-on-LAN packet to wake up a device. " + "Accepts JSON with 'devMac' or 'devLastIP'." + } + }, + "/devices/export": { + "get": { + "operationId": "export_devices", + "description": "Export devices in CSV or JSON format. " + "Accepts optional 'format' query parameter (csv/json, defaults to csv)." + } + }, + "/devices/import": { + "post": { + "operationId": "import_devices", + "description": "Import devices from CSV or JSON content. " + "Accepts JSON with 'content' field containing base64-encoded data, or multipart file upload." + } + }, + "/devices/totals": { + "get": { + "operationId": "get_device_totals", + "description": "Get device statistics and counts." + } + }, + "/nettools/traceroute": { + "post": { + "operationId": "traceroute", + "description": "Perform a traceroute to a target IP address. " + "Accepts JSON with 'devLastIP' field." + } + } } } return jsonify(spec) @@ -57,11 +179,20 @@ def openapi_spec(): # Sessions for SSE -_openapi_spec_cache = None -API_BASE_URL = f"http://localhost:{get_setting_value('GRAPHQL_PORT')}" +_openapi_spec_cache = None # Cached OpenAPI spec to avoid repeated generation +API_BASE_URL = f"http://localhost:{get_setting_value('GRAPHQL_PORT')}" # Base URL for internal API calls def get_openapi_spec(): + """ + Retrieve the cached OpenAPI specification for MCP tools. + + This function caches the OpenAPI spec to avoid repeated generation. + If the cache is empty, it calls openapi_spec() to generate it. + + Returns: + dict or None: The OpenAPI spec as a dictionary, or None if generation fails. + """ global _openapi_spec_cache if _openapi_spec_cache: @@ -78,6 +209,15 @@ def get_openapi_spec(): def map_openapi_to_mcp_tools(spec): + """ + Convert an OpenAPI specification into MCP tool definitions. + + Args: + spec (dict): The OpenAPI spec dictionary. + + Returns: + list: A list of MCP tool dictionaries, each containing name, description, and inputSchema. + """ tools = [] if not spec or 'paths' not in spec: return tools @@ -101,6 +241,18 @@ def map_openapi_to_mcp_tools(spec): def process_mcp_request(data): + """ + Process an incoming MCP JSON-RPC request. + + Handles various MCP methods like initialize, tools/list, tools/call, etc. + For tools/call, it maps the tool name to an API endpoint and makes the call. + + Args: + data (dict): The JSON-RPC request data containing method, id, params, etc. + + Returns: + dict or None: The JSON-RPC response, or None for notifications. + """ method = data.get('method') msg_id = data.get('id') if method == 'initialize': @@ -157,6 +309,15 @@ def process_mcp_request(data): def mcp_messages(): + """ + Handle MCP messages for a specific session via HTTP POST. + + This endpoint processes JSON-RPC requests for an existing MCP session. + The session_id is passed as a query parameter. + + Returns: + flask.Response: JSON response indicating acceptance or error. + """ session_id = request.args.get('session_id') if not session_id: return jsonify({"error": "Missing session_id"}), 400 @@ -174,6 +335,16 @@ def mcp_messages(): def mcp_sse(): + """ + Handle MCP Server-Sent Events (SSE) endpoint. + + Supports both GET (for establishing SSE stream) and POST (for direct JSON-RPC). + For GET, creates a new session and streams responses. + For POST, processes the request directly and returns the response. + + Returns: + flask.Response: SSE stream for GET, JSON response for POST. + """ if request.method == 'POST': try: data = request.get_json(silent=True) diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 97ee4400..a3627822 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -1,7 +1,16 @@ +import os +import base64 +import re +import sqlite3 +import csv +from io import StringIO 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 +from db.db_helper import get_table_json, get_device_condition_by_status, row_to_json, get_date_from_period +from helper import is_random_mac, get_setting_value +from utils.datetime_utils import timeNowDB, format_date class DeviceInstance: @@ -83,6 +92,12 @@ class DeviceInstance: ORDER BY devFirstConnection DESC LIMIT 1 """) + def getFavorite(self): + return self._fetchall(""" + SELECT * FROM Devices + HERE devFavorite = 1 + """) + def getNetworkTopology(self): rows = self._fetchall(""" SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices @@ -132,3 +147,537 @@ class DeviceInstance: ports.append({"port": port, "service": o.get('Watched_Value2', '')}) return ports + + # --- devices_endpoint.py methods (HTTP response layer) ------------------- + + def getAll_AsResponse(self): + """Return all devices as raw data (not jsonified).""" + return self.getAll() + + def deleteDevices(self, macs): + """ + Delete devices from the Devices table. + - If `macs` is None → delete ALL devices. + - If `macs` is a list → delete only matching MACs (supports wildcard '*'). + """ + conn = get_temp_db_connection() + cur = conn.cursor() + + if not macs: + # No MACs provided → delete all + cur.execute("DELETE FROM Devices") + conn.commit() + conn.close() + return {"success": True, "deleted": "all"} + + deleted_count = 0 + + for mac in macs: + if "*" in mac: + # Wildcard matching + sql_pattern = mac.replace("*", "%") + cur.execute("DELETE FROM Devices WHERE devMAC LIKE ?", (sql_pattern,)) + else: + # Exact match + cur.execute("DELETE FROM Devices WHERE devMAC = ?", (mac,)) + deleted_count += cur.rowcount + + conn.commit() + conn.close() + + return {"success": True, "deleted_count": deleted_count} + + def deleteAllWithEmptyMacs(self): + """Delete devices with empty MAC addresses.""" + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute("DELETE FROM Devices WHERE devMAC IS NULL OR devMAC = ''") + deleted = cur.rowcount + conn.commit() + conn.close() + return {"success": True, "deleted": deleted} + + def deleteUnknownDevices(self): + """Delete devices marked as unknown.""" + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute( + """DELETE FROM Devices WHERE devName='(unknown)' OR devName='(name not found)'""" + ) + deleted = cur.rowcount + conn.commit() + conn.close() + return {"success": True, "deleted": deleted} + + def exportDevices(self, export_format): + """ + Export devices from the Devices table in the desired format. + """ + conn = get_temp_db_connection() + cur = conn.cursor() + + # Fetch all devices + devices_json = get_table_json(cur, "SELECT * FROM Devices") + conn.close() + + # Ensure columns exist + columns = devices_json.columnNames or ( + list(devices_json["data"][0].keys()) if devices_json["data"] else [] + ) + + if export_format == "json": + return { + "format": "json", + "data": [row for row in devices_json["data"]], + "columns": list(columns) + } + elif export_format == "csv": + si = StringIO() + writer = csv.DictWriter(si, fieldnames=columns, quoting=csv.QUOTE_ALL) + writer.writeheader() + for row in devices_json.json["data"]: + writer.writerow(row) + return { + "format": "csv", + "content": si.getvalue(), + } + else: + return {"error": f"Unsupported format '{export_format}'"} + + def importCSV(self, file_storage=None, json_content=None): + """ + Import devices from CSV. + - json_content: base64-encoded CSV string + - file_storage: uploaded file object + - fallback: read from config/devices.csv + """ + data = "" + skipped = [] + + # 1. Try JSON `content` (base64-encoded CSV) + if json_content: + try: + data = base64.b64decode(json_content, validate=True).decode("utf-8") + except Exception as e: + return {"success": False, "error": f"Base64 decode failed: {e}"} + + # 2. Otherwise, try uploaded file + elif file_storage: + try: + data = file_storage.read().decode("utf-8") + except Exception as e: + return {"success": False, "error": f"File read failed: {e}"} + + # 3. Fallback: try local file (same as PHP `$file = '../../../config/devices.csv';`) + else: + config_root = os.environ.get("NETALERTX_CONFIG", "/data/config") + local_file = os.path.join(config_root, "devices.csv") + try: + with open(local_file, "r", encoding="utf-8") as f: + data = f.read() + except FileNotFoundError: + return {"success": False, "error": "CSV file missing"} + + if not data: + return {"success": False, "error": "No CSV data found"} + + # --- Clean up newlines inside quoted fields --- + data = re.sub(r'"([^"]*)"', lambda m: m.group(0).replace("\n", " "), data) + + # --- Parse CSV --- + lines = data.splitlines() + reader = csv.reader(lines) + try: + header = [h.strip() for h in next(reader)] + except StopIteration: + return {"success": False, "error": "CSV missing header"} + + # --- Wipe Devices table --- + conn = get_temp_db_connection() + sql = conn.cursor() + sql.execute("DELETE FROM Devices") + + # --- Prepare insert --- + placeholders = ",".join(["?"] * len(header)) + insert_sql = f"INSERT INTO Devices ({', '.join(header)}) VALUES ({placeholders})" + + row_count = 0 + for idx, row in enumerate(reader, start=1): + if len(row) != len(header): + skipped.append(idx) + continue + try: + sql.execute(insert_sql, [col.strip() for col in row]) + row_count += 1 + except sqlite3.Error as e: + mylog("error", [f"[ImportCSV] SQL ERROR row {idx}: {e}"]) + skipped.append(idx) + + conn.commit() + conn.close() + + return {"success": True, "inserted": row_count, "skipped_lines": skipped} + + def getTotals(self): + """Get device totals by status.""" + conn = get_temp_db_connection() + sql = conn.cursor() + + # Build a combined query with sub-selects for each status + query = f""" + SELECT + (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("my")}) AS devices, + (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("connected")}) AS connected, + (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("favorites")}) AS favorites, + (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("new")}) AS new, + (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("down")}) AS down, + (SELECT COUNT(*) FROM Devices {get_device_condition_by_status("archived")}) AS archived + """ + sql.execute(query) + row = sql.fetchone() + conn.close() + + return list(row) if row else [] + + def getByStatus(self, status=None): + """ + Return devices filtered by status. Returns all if no status provided. + Possible statuses: my, connected, favorites, new, down, archived + """ + conn = get_temp_db_connection() + sql = conn.cursor() + + # Build condition for SQL + condition = get_device_condition_by_status(status) if status else "" + + query = f"SELECT * FROM Devices {condition}" + sql.execute(query) + + table_data = [] + for row in sql.fetchall(): + r = dict(row) # Convert sqlite3.Row to dict for .get() + dev_name = r.get("devName", "") + if r.get("devFavorite") == 1: + dev_name = f' {dev_name}' + + # Start with all fields from the device record + device_record = r.copy() + # Override with formatted fields + device_record["id"] = r.get("devMac", "") + device_record["title"] = dev_name + device_record["favorite"] = r.get("devFavorite", 0) + + table_data.append(device_record) + + conn.close() + return table_data + + # --- device_endpoint.py methods ------------------------------------------- + + def getDeviceData(self, mac, period=""): + """Fetch device info with children, event stats, and presence calculation.""" + now = timeNowDB() + + # Special case for new device + if mac.lower() == "new": + device_data = { + "devMac": "", + "devName": "", + "devOwner": "", + "devType": "", + "devVendor": "", + "devFavorite": 0, + "devGroup": "", + "devComments": "", + "devFirstConnection": now, + "devLastConnection": now, + "devLastIP": "", + "devStaticIP": 0, + "devScan": 0, + "devLogEvents": 0, + "devAlertEvents": 0, + "devAlertDown": 0, + "devParentRelType": "default", + "devReqNicsOnline": 0, + "devSkipRepeated": 0, + "devLastNotification": "", + "devPresentLastScan": 0, + "devIsNew": 1, + "devLocation": "", + "devIsArchived": 0, + "devParentMAC": "", + "devParentPort": "", + "devIcon": "", + "devGUID": "", + "devSite": "", + "devSSID": "", + "devSyncHubNode": "", + "devSourcePlugin": "", + "devCustomProps": "", + "devStatus": "Unknown", + "devIsRandomMAC": False, + "devSessions": 0, + "devEvents": 0, + "devDownAlerts": 0, + "devPresenceHours": 0, + "devFQDN": "", + } + return device_data + + # Compute period date for sessions/events + period_date_sql = get_date_from_period(period) + + # Fetch device info + computed fields + sql = f""" + SELECT + d.*, + CASE + WHEN d.devAlertDown != 0 AND d.devPresentLastScan = 0 THEN 'Down' + WHEN d.devPresentLastScan = 1 THEN 'On-line' + ELSE 'Off-line' + END AS devStatus, + + (SELECT COUNT(*) FROM Sessions + WHERE ses_MAC = d.devMac AND ( + ses_DateTimeConnection >= {period_date_sql} OR + ses_DateTimeDisconnection >= {period_date_sql} OR + ses_StillConnected = 1 + )) AS devSessions, + + (SELECT COUNT(*) FROM Events + WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql} + AND eve_EventType NOT IN ('Connected','Disconnected')) AS devEvents, + + (SELECT COUNT(*) FROM Events + WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql} + AND eve_EventType = 'Device Down') AS devDownAlerts, + + (SELECT CAST(MAX(0, SUM( + julianday(IFNULL(ses_DateTimeDisconnection,'{now}')) - + julianday(CASE WHEN ses_DateTimeConnection < {period_date_sql} + THEN {period_date_sql} ELSE ses_DateTimeConnection END) + ) * 24) AS INT) + FROM Sessions + WHERE ses_MAC = d.devMac + AND ses_DateTimeConnection IS NOT NULL + AND (ses_DateTimeDisconnection IS NOT NULL OR ses_StillConnected = 1) + AND (ses_DateTimeConnection >= {period_date_sql} + OR ses_DateTimeDisconnection >= {period_date_sql} OR ses_StillConnected = 1) + ) AS devPresenceHours + + FROM Devices d + WHERE d.devMac = ? OR CAST(d.rowid AS TEXT) = ? + """ + + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute(sql, (mac, mac)) + row = cur.fetchone() + + if not row: + conn.close() + return None + + device_data = row_to_json(list(row.keys()), row) + device_data["devFirstConnection"] = format_date(device_data["devFirstConnection"]) + device_data["devLastConnection"] = format_date(device_data["devLastConnection"]) + device_data["devIsRandomMAC"] = is_random_mac(device_data["devMac"]) + + # Fetch children + cur.execute( + "SELECT * FROM Devices WHERE devParentMAC = ? ORDER BY devPresentLastScan DESC", + (device_data["devMac"],), + ) + children_rows = cur.fetchall() + children = [row_to_json(list(r.keys()), r) for r in children_rows] + children_nics = [c for c in children if c.get("devParentRelType") == "nic"] + + device_data["devChildrenDynamic"] = children + device_data["devChildrenNicsDynamic"] = children_nics + + conn.close() + return device_data + + def setDeviceData(self, mac, data): + """Update or create a device.""" + if data.get("createNew", False): + sql = """ + INSERT INTO Devices ( + devMac, devName, devOwner, devType, devVendor, devIcon, + devFavorite, devGroup, devLocation, devComments, + devParentMAC, devParentPort, devSSID, devSite, + devStaticIP, devScan, devAlertEvents, devAlertDown, + devParentRelType, devReqNicsOnline, devSkipRepeated, + devIsNew, devIsArchived, devLastConnection, + devFirstConnection, devLastIP, devGUID, devCustomProps, + devSourcePlugin + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + + values = ( + mac, + data.get("devName", ""), + data.get("devOwner", ""), + data.get("devType", ""), + data.get("devVendor", ""), + data.get("devIcon", ""), + data.get("devFavorite", 0), + data.get("devGroup", ""), + data.get("devLocation", ""), + data.get("devComments", ""), + data.get("devParentMAC", ""), + data.get("devParentPort", ""), + data.get("devSSID", ""), + data.get("devSite", ""), + data.get("devStaticIP", 0), + data.get("devScan", 0), + data.get("devAlertEvents", 0), + data.get("devAlertDown", 0), + data.get("devParentRelType", "default"), + data.get("devReqNicsOnline", 0), + data.get("devSkipRepeated", 0), + data.get("devIsNew", 0), + data.get("devIsArchived", 0), + data.get("devLastConnection", timeNowDB()), + data.get("devFirstConnection", timeNowDB()), + data.get("devLastIP", ""), + data.get("devGUID", ""), + data.get("devCustomProps", ""), + data.get("devSourcePlugin", "DUMMY"), + ) + + else: + sql = """ + UPDATE Devices SET + devName=?, devOwner=?, devType=?, devVendor=?, devIcon=?, + devFavorite=?, devGroup=?, devLocation=?, devComments=?, + devParentMAC=?, devParentPort=?, devSSID=?, devSite=?, + devStaticIP=?, devScan=?, devAlertEvents=?, devAlertDown=?, + devParentRelType=?, devReqNicsOnline=?, devSkipRepeated=?, + devIsNew=?, devIsArchived=?, devCustomProps=? + WHERE devMac=? + """ + values = ( + data.get("devName", ""), + data.get("devOwner", ""), + data.get("devType", ""), + data.get("devVendor", ""), + data.get("devIcon", ""), + data.get("devFavorite", 0), + data.get("devGroup", ""), + data.get("devLocation", ""), + data.get("devComments", ""), + data.get("devParentMAC", ""), + data.get("devParentPort", ""), + data.get("devSSID", ""), + data.get("devSite", ""), + data.get("devStaticIP", 0), + data.get("devScan", 0), + data.get("devAlertEvents", 0), + data.get("devAlertDown", 0), + data.get("devParentRelType", "default"), + data.get("devReqNicsOnline", 0), + data.get("devSkipRepeated", 0), + data.get("devIsNew", 0), + data.get("devIsArchived", 0), + data.get("devCustomProps", ""), + mac, + ) + + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute(sql, values) + conn.commit() + conn.close() + return {"success": True} + + def deleteDeviceByMAC(self, mac): + """Delete a device by MAC.""" + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute("DELETE FROM Devices WHERE devMac=?", (mac,)) + conn.commit() + conn.close() + return {"success": True} + + def deleteDeviceEvents(self, mac): + """Delete all events for a device.""" + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute("DELETE FROM Events WHERE eve_MAC=?", (mac,)) + conn.commit() + conn.close() + return {"success": True} + + def resetDeviceProps(self, mac): + """Reset device custom properties to default.""" + default_props = get_setting_value("NEWDEV_devCustomProps") + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute( + "UPDATE Devices SET devCustomProps=? WHERE devMac=?", + (default_props, mac), + ) + conn.commit() + conn.close() + return {"success": True} + + def updateDeviceColumn(self, mac, column_name, column_value): + """Update a specific column for a given device.""" + conn = get_temp_db_connection() + cur = conn.cursor() + + # Build safe SQL with column name + sql = f"UPDATE Devices SET {column_name}=? WHERE devMac=?" + cur.execute(sql, (column_value, mac)) + conn.commit() + + if cur.rowcount > 0: + result = {"success": True} + else: + result = {"success": False, "error": "Device not found"} + + conn.close() + return result + + def copyDevice(self, mac_from, mac_to): + """Copy a device entry from one MAC to another.""" + conn = get_temp_db_connection() + cur = conn.cursor() + + try: + # Drop temporary table if exists + cur.execute("DROP TABLE IF EXISTS temp_devices") + + # Create temporary table with source device + cur.execute( + "CREATE TABLE temp_devices AS SELECT * FROM Devices WHERE devMac = ?", + (mac_from,), + ) + + # Update temporary table to target MAC + cur.execute("UPDATE temp_devices SET devMac = ?", (mac_to,)) + + # Delete previous entry with target MAC + cur.execute("DELETE FROM Devices WHERE devMac = ?", (mac_to,)) + + # Insert new entry from temporary table + cur.execute( + "INSERT INTO Devices SELECT * FROM temp_devices WHERE devMac = ?", (mac_to,) + ) + + # Drop temporary table + cur.execute("DROP TABLE temp_devices") + + conn.commit() + return { + "success": True, + "message": f"Device copied from {mac_from} to {mac_to}", + } + + except Exception as e: + conn.rollback() + return {"success": False, "error": str(e)} + + finally: + conn.close() diff --git a/server/models/event_instance.py b/server/models/event_instance.py index bdb5960f..4742e7a3 100644 --- a/server/models/event_instance.py +++ b/server/models/event_instance.py @@ -1,6 +1,8 @@ from datetime import datetime, timedelta from logger import mylog from database import get_temp_db_connection +from db.db_helper import row_to_json, get_date_from_period +from utils.datetime_utils import ensure_datetime # ------------------------------------------------------------------------------- @@ -105,3 +107,114 @@ class EventInstance: deleted_count = result.rowcount conn.close() return deleted_count + + # --- events_endpoint.py methods --- + + def createEvent(self, mac: str, ip: str, event_type: str = "Device Down", additional_info: str = "", pending_alert: int = 1, event_time: datetime | None = None): + """ + Insert a single event into the Events table. + Returns dict with success status. + """ + if isinstance(event_time, str): + start_time = ensure_datetime(event_time) + else: + start_time = ensure_datetime(event_time) + + conn = self._conn() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType, eve_AdditionalInfo, eve_PendingAlertEmail) + VALUES (?, ?, ?, ?, ?, ?) + """, + (mac, ip, start_time, event_type, additional_info, pending_alert), + ) + + conn.commit() + conn.close() + + mylog("debug", f"[Events] Created event for {mac} ({event_type})") + return {"success": True, "message": f"Created event for {mac}"} + + def getEvents(self, mac=None): + """ + Fetch all events, or events for a specific MAC if provided. + Returns list of events. + """ + conn = self._conn() + cur = conn.cursor() + + if mac: + sql = "SELECT * FROM Events WHERE eve_MAC=? ORDER BY eve_DateTime DESC" + cur.execute(sql, (mac,)) + else: + sql = "SELECT * FROM Events ORDER BY eve_DateTime DESC" + cur.execute(sql) + + rows = cur.fetchall() + events = [row_to_json(list(r.keys()), r) for r in rows] + + conn.close() + return events + + def deleteEventsOlderThan(self, days): + """Delete all events older than a specified number of days""" + conn = self._conn() + cur = conn.cursor() + + # Use a parameterized query with sqlite date function + sql = "DELETE FROM Events WHERE eve_DateTime <= date('now', ?)" + cur.execute(sql, [f"-{days} days"]) + + conn.commit() + conn.close() + + return {"success": True, "message": f"Deleted events older than {days} days"} + + def deleteAllEvents(self): + """Delete all events""" + conn = self._conn() + cur = conn.cursor() + + sql = "DELETE FROM Events" + cur.execute(sql) + conn.commit() + conn.close() + + return {"success": True, "message": "Deleted all events"} + + def getEventsTotals(self, period: str = "7 days"): + """ + Return counts for events and sessions totals over a given period. + period: "7 days", "1 month", "1 year", "100 years" + Returns list with counts: [all_events, sessions, missing, voided, new, down] + """ + # Convert period to SQLite date expression + period_date_sql = get_date_from_period(period) + + conn = self._conn() + cur = conn.cursor() + + sql = f""" + SELECT + (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql}) AS all_events, + (SELECT COUNT(*) FROM Sessions WHERE + ses_DateTimeConnection >= {period_date_sql} + OR ses_DateTimeDisconnection >= {period_date_sql} + OR ses_StillConnected = 1 + ) AS sessions, + (SELECT COUNT(*) FROM Sessions WHERE + (ses_DateTimeConnection IS NULL AND ses_DateTimeDisconnection >= {period_date_sql}) + OR (ses_DateTimeDisconnection IS NULL AND ses_StillConnected = 0 AND ses_DateTimeConnection >= {period_date_sql}) + ) AS missing, + (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql} AND eve_EventType LIKE 'VOIDED%') AS voided, + (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql} AND eve_EventType LIKE 'New Device') AS new, + (SELECT COUNT(*) FROM Events WHERE eve_DateTime >= {period_date_sql} AND eve_EventType LIKE 'Device Down') AS down + """ + + cur.execute(sql) + row = cur.fetchone() + conn.close() + + # Return as list + return [row[0], row[1], row[2], row[3], row[4], row[5]] diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index 92329ba1..a833c65e 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -200,7 +200,7 @@ def test_get_recent_alerts(mock_db_conn, client, api_token): # --- Device Alias Tests --- -@patch('api_server.api_server_start.update_device_column') +@patch('models.device_instance.DeviceInstance.updateDeviceColumn') def test_set_device_alias(mock_update_col, client, api_token): """Test set_device_alias.""" mock_update_col.return_value = {"success": True, "message": "Device alias updated"} @@ -216,7 +216,7 @@ def test_set_device_alias(mock_update_col, client, api_token): mock_update_col.assert_called_once_with("AA:BB:CC:DD:EE:FF", "devName", "New Device Name") -@patch('api_server.api_server_start.update_device_column') +@patch('models.device_instance.DeviceInstance.updateDeviceColumn') def test_set_device_alias_not_found(mock_update_col, client, api_token): """Test set_device_alias when device is not found.""" mock_update_col.return_value = {"success": False, "error": "Device not found"} @@ -304,3 +304,134 @@ def test_openapi_spec(client, api_token): assert "/events/recent" in spec["paths"] assert "/device/{mac}/set-alias" in spec["paths"] assert "/nettools/wakeonlan" in spec["paths"] + # Check for newly added MCP endpoints + assert "/devices/export" in spec["paths"] + assert "/devices/import" in spec["paths"] + assert "/devices/totals" in spec["paths"] + assert "/nettools/traceroute" in spec["paths"] + + +# --- MCP Device Export Tests --- + +@patch('models.device_instance.get_temp_db_connection') +def test_mcp_devices_export_csv(mock_db_conn, client, api_token): + """Test MCP devices export in CSV format.""" + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchall.return_value = [ + {"devMac": "AA:BB:CC:DD:EE:FF", "devName": "Test Device", "devLastIP": "192.168.1.1"} + ] + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn + + response = client.get('/mcp/sse/devices/export', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + # CSV response should have content-type header + assert 'text/csv' in response.content_type + assert 'attachment; filename=devices.csv' in response.headers.get('Content-Disposition', '') + + +@patch('models.device_instance.DeviceInstance.exportDevices') +def test_mcp_devices_export_json(mock_export, client, api_token): + """Test MCP devices export in JSON format.""" + mock_export.return_value = { + "format": "json", + "data": [{"devMac": "AA:BB:CC:DD:EE:FF", "devName": "Test Device", "devLastIP": "192.168.1.1"}], + "columns": ["devMac", "devName", "devLastIP"] + } + + response = client.get('/mcp/sse/devices/export?format=json', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert "data" in data + assert "columns" in data + assert len(data["data"]) == 1 + + +# --- MCP Device Import Tests --- + +@patch('models.device_instance.get_temp_db_connection') +def test_mcp_devices_import_json(mock_db_conn, client, api_token): + """Test MCP devices import from JSON content.""" + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn + + # Mock successful import + with patch('models.device_instance.DeviceInstance.importCSV') as mock_import: + mock_import.return_value = {"success": True, "message": "Imported 2 devices"} + + payload = {"content": "bW9ja2VkIGNvbnRlbnQ="} # base64 encoded content + response = client.post('/mcp/sse/devices/import', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "Imported 2 devices" in data["message"] + + +# --- MCP Device Totals Tests --- + +@patch('database.get_temp_db_connection') +def test_mcp_devices_totals(mock_db_conn, client, api_token): + """Test MCP devices totals endpoint.""" + mock_conn = MagicMock() + mock_sql = MagicMock() + mock_execute_result = MagicMock() + # Mock the getTotals method to return sample data + mock_execute_result.fetchone.return_value = [10, 8, 2, 0, 1, 3] # devices, connected, favorites, new, down, archived + mock_sql.execute.return_value = mock_execute_result + mock_conn.cursor.return_value = mock_sql + mock_db_conn.return_value = mock_conn + + response = client.get('/mcp/sse/devices/totals', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + # Should return device counts as array + assert isinstance(data, list) + assert len(data) >= 4 # At least online, offline, etc. + + +# --- MCP Traceroute Tests --- + +@patch('api_server.api_server_start.traceroute') +def test_mcp_traceroute(mock_traceroute, client, api_token): + """Test MCP traceroute endpoint.""" + mock_traceroute.return_value = ({"success": True, "output": "traceroute output"}, 200) + + payload = {"devLastIP": "8.8.8.8"} + response = client.post('/mcp/sse/nettools/traceroute', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "output" in data + mock_traceroute.assert_called_once_with("8.8.8.8") + + +@patch('api_server.api_server_start.traceroute') +def test_mcp_traceroute_missing_ip(mock_traceroute, client, api_token): + """Test MCP traceroute with missing IP.""" + mock_traceroute.return_value = ({"success": False, "error": "Invalid IP: None"}, 400) + + payload = {} # Missing devLastIP + response = client.post('/mcp/sse/nettools/traceroute', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + assert "error" in data + mock_traceroute.assert_called_once_with(None)