diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9a179c80..73f1e89f 100755 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,7 @@ // even within this container and connect to them as needed. // "--network=host", ], - "mounts": [ + "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" //used for testing various conditions in docker ], // ATTENTION: If running with --network=host, COMMENT `forwardPorts` OR ELSE THERE WILL BE NO WEBUI! @@ -88,7 +88,7 @@ } }, "terminal.integrated.defaultProfile.linux": "zsh", - + // Python testing configuration "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 522eed73..6d1a3c91 100755 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -39,10 +39,11 @@ Backend loop phases (see `server/__main__.py` and `server/plugin.py`): `once`, ` ## API/Endpoints quick map - Flask app: `server/api_server/api_server_start.py` exposes routes like `/device/`, `/devices`, `/devices/export/{csv,json}`, `/devices/import`, `/devices/totals`, `/devices/by-status`, plus `nettools`, `events`, `sessions`, `dbquery`, `metrics`, `sync`. - Authorization: all routes expect header `Authorization: Bearer ` via `get_setting_value('API_TOKEN')`. +- All responses need to return `"success":` and if `False` an "error" message needs to be returned, e.g. `{"success": False, "error": f"No stored open ports for Device"}` ## Conventions & helpers to reuse - Settings: add/modify via `ccd()` in `server/initialise.py` or per‑plugin manifest. Never hardcode ports or secrets; use `get_setting_value()`. -- Logging: use `logger.mylog(level, [message])`; levels: none/minimal/verbose/debug/trace. +- Logging: use `mylog(level, [message])`; levels: none/minimal/verbose/debug/trace. `none` is used for most important messages that should always appear, such as exceptions. - Time/MAC/strings: `helper.py` (`timeNowDB`, `normalize_mac`, sanitizers). Validate MACs before DB writes. - DB helpers: prefer `server/db/db_helper.py` functions (e.g., `get_table_json`, device condition helpers) over raw SQL in new paths. @@ -85,7 +86,7 @@ Backend loop phases (see `server/__main__.py` and `server/plugin.py`): `once`, ` - Above all, use the simplest possible code that meets the need so it can be easily audited and maintained. - Always leave logging enabled. If there is a possiblity it will be difficult to debug with current logging, add more logging. - Always run the testFailure tool before executing any tests to gather current failure information and avoid redundant runs. -- Always prioritize using the appropriate tools in the environment first. As an example if a test is failing use `testFailure` then `runTests`. Never `runTests` first. +- Always prioritize using the appropriate tools in the environment first. As an example if a test is failing use `testFailure` then `runTests`. Never `runTests` first. - Docker tests take an extremely long time to run. Avoid changes to docker or tests until you've examined the exisiting testFailures and runTests results. -- Environment tools are designed specifically for your use in this project and running them in this order will give you the best results. +- Environment tools are designed specifically for your use in this project and running them in this order will give you the best results. diff --git a/.github/workflows/docker_dev.yml b/.github/workflows/docker_dev.yml index 5314e178..d264f8dc 100755 --- a/.github/workflows/docker_dev.yml +++ b/.github/workflows/docker_dev.yml @@ -13,7 +13,7 @@ on: jobs: docker_dev: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read packages: write @@ -96,3 +96,5 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker_prod.yml b/.github/workflows/docker_prod.yml index f3039ad9..22e0ae6d 100755 --- a/.github/workflows/docker_prod.yml +++ b/.github/workflows/docker_prod.yml @@ -17,7 +17,7 @@ on: jobs: docker: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read packages: write diff --git a/.github/workflows/social_post_on_release.yml b/.github/workflows/social_post_on_release.yml index cf559ee3..eed6b3dc 100755 --- a/.github/workflows/social_post_on_release.yml +++ b/.github/workflows/social_post_on_release.yml @@ -7,8 +7,8 @@ jobs: post-discord: runs-on: ubuntu-latest steps: - - name: Wait for 15 minutes - run: sleep 900 # 15 minutes delay + - name: Wait for 60 minutes + run: sleep 3600 # 60 minutes delay - name: Post to Discord run: | diff --git a/Dockerfile b/Dockerfile index fa45744d..babc093f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -26,13 +26,23 @@ ENV PATH="/opt/venv/bin:$PATH" # Install build dependencies COPY requirements.txt /tmp/requirements.txt -RUN apk add --no-cache bash shadow python3 python3-dev gcc musl-dev libffi-dev openssl-dev git \ +RUN apk add --no-cache \ + bash \ + shadow \ + python3 \ + python3-dev \ + gcc \ + musl-dev \ + libffi-dev \ + openssl-dev \ + git \ + rust \ + cargo \ && python -m venv /opt/venv -# Create virtual environment owned by root, but readable by everyone else. This makes it easy to copy -# into hardened stage without worrying about permissions and keeps image size small. Keeping the commands -# together makes for a slightly smaller image size. -RUN pip install --no-cache-dir -r /tmp/requirements.txt && \ +# Upgrade pip/wheel/setuptools and install Python packages +RUN python -m pip install --upgrade pip setuptools wheel && \ + pip install --prefer-binary --no-cache-dir -r /tmp/requirements.txt && \ chmod -R u-rwx,g-rwx /opt # second stage is the main runtime stage with just the minimum required to run the application diff --git a/docs/API.md b/docs/API.md index 3ad69a96..8a11403d 100755 --- a/docs/API.md +++ b/docs/API.md @@ -36,9 +36,15 @@ Authorization: Bearer If the token is missing or invalid, the server will return: ```json -{ "error": "Forbidden" } +{ + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" +} ``` +HTTP Status: **403 Forbidden** + --- ## Base URL @@ -54,6 +60,8 @@ http://:/ > [!TIP] > When retrieving devices or settings try using the GraphQL API endpoint first as it is read-optimized. +### Standard REST Endpoints + * [Device API Endpoints](API_DEVICE.md) – Manage individual devices * [Devices Collection](API_DEVICES.md) – Bulk operations on multiple devices * [Events](API_EVENTS.md) – Device event logging and management @@ -69,6 +77,18 @@ http://:/ * [Logs](API_LOGS.md) – Purging of logs and adding to the event execution queue for user triggered events * [DB query](API_DBQUERY.md) (⚠ Internal) - Low level database access - use other endpoints if possible +### MCP Server Bridge + +NetAlertX includes an **MCP (Model Context Protocol) Server Bridge** that provides AI assistants access to NetAlertX functionality through standardized tools. MCP endpoints are available at `/mcp/sse/*` paths and mirror the functionality of standard REST endpoints: + +* `/mcp/sse` - Server-Sent Events endpoint for MCP client connections +* `/mcp/sse/openapi.json` - OpenAPI specification for available MCP tools +* `/mcp/sse/device/*`, `/mcp/sse/devices/*`, `/mcp/sse/nettools/*`, `/mcp/sse/events/*` - MCP-enabled versions of REST endpoints + +MCP endpoints require the same Bearer token authentication as REST endpoints. + +**📖 See [MCP Server Bridge API](API_MCP.md) for complete documentation, tool specifications, and integration examples.** + See [Testing](API_TESTS.md) for example requests and usage. --- diff --git a/docs/API_DBQUERY.md b/docs/API_DBQUERY.md index a896d1c7..4ad04f22 100755 --- a/docs/API_DBQUERY.md +++ b/docs/API_DBQUERY.md @@ -2,7 +2,7 @@ The **Database Query API** provides direct, low-level access to the NetAlertX database. It allows **read, write, update, and delete** operations against tables, using **base64-encoded** SQL or structured parameters. -> [!Warning] +> [!Warning] > This API is primarily used internally to generate and render the application UI. These endpoints are low-level and powerful, and should be used with caution. Wherever possible, prefer the [standard API endpoints](API.md). Invalid or unsafe queries can corrupt data. > If you need data in a specific format that is not already provided, please open an issue or pull request with a clear, broadly useful use case. This helps ensure new endpoints benefit the wider community rather than relying on raw database queries. @@ -16,10 +16,14 @@ All `/dbquery/*` endpoints require an API token in the HTTP headers: Authorization: Bearer ``` -If the token is missing or invalid: +If the token is missing or invalid (HTTP 403): ```json -{ "error": "Forbidden" } +{ + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" +} ``` --- diff --git a/docs/API_DEVICE.md b/docs/API_DEVICE.md index de9d283c..99692c3c 100755 --- a/docs/API_DEVICE.md +++ b/docs/API_DEVICE.md @@ -41,6 +41,8 @@ Manage a **single device** by its MAC address. Operations include retrieval, upd * Device not found → HTTP 404 * Unauthorized → HTTP 403 +**MCP Integration**: Available as `get_device_info` and `set_device_alias` tools. See [MCP Server Bridge API](API_MCP.md). + --- ## 2. Update Device Fields diff --git a/docs/API_DEVICES.md b/docs/API_DEVICES.md index 1ea390e1..42fa471c 100755 --- a/docs/API_DEVICES.md +++ b/docs/API_DEVICES.md @@ -170,7 +170,7 @@ The Devices Collection API provides operations to **retrieve, manage, import/exp **Response**: ```json -[ +[ 120, // Total devices 85, // Connected 5, // Favorites @@ -207,6 +207,93 @@ The Devices Collection API provides operations to **retrieve, manage, import/exp --- +### 9. Search Devices + +* **POST** `/devices/search` + Search for devices by MAC, name, or IP address. + +**Request Body** (JSON): + +```json +{ + "query": ".50" +} +``` + +**Response**: + +```json +{ + "success": true, + "devices": [ + { + "devName": "Test Device", + "devMac": "AA:BB:CC:DD:EE:FF", + "devLastIP": "192.168.1.50" + } + ] +} +``` + +--- + +### 10. Get Latest Device + +* **GET** `/devices/latest` + Get the most recently connected device. + +**Response**: + +```json +[ + { + "devName": "Latest Device", + "devMac": "AA:BB:CC:DD:EE:FF", + "devLastIP": "192.168.1.100", + "devFirstConnection": "2025-12-07 10:30:00" + } +] +``` + +--- + +### 11. Get Network Topology + +* **GET** `/devices/network/topology` + Get network topology showing device relationships. + +**Response**: + +```json +{ + "nodes": [ + { + "id": "AA:AA:AA:AA:AA:AA", + "name": "Router", + "vendor": "VendorA" + } + ], + "links": [ + { + "source": "AA:AA:AA:AA:AA:AA", + "target": "BB:BB:BB:BB:BB:BB", + "port": "eth1" + } + ] +} +``` + +--- + +## MCP Tools + +These endpoints are also available as **MCP Tools** for AI assistant integration: +- `list_devices`, `search_devices`, `get_latest_device`, `get_network_topology`, `set_device_alias` + +📖 See [MCP Server Bridge API](API_MCP.md) for AI integration details. + +--- + ## Example `curl` Requests **Get All Devices**: @@ -247,3 +334,26 @@ curl -X GET "http://:/devices/by-status?status=online" -H "Authorization: Bearer " ``` +**Search Devices**: + +```sh +curl -X POST "http://:/devices/search" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + --data '{"query": "192.168.1"}' +``` + +**Get Latest Device**: + +```sh +curl -X GET "http://:/devices/latest" \ + -H "Authorization: Bearer " +``` + +**Get Network Topology**: + +```sh +curl -X GET "http://:/devices/network/topology" \ + -H "Authorization: Bearer " +``` + diff --git a/docs/API_EVENTS.md b/docs/API_EVENTS.md index c845e10d..ff423c4f 100755 --- a/docs/API_EVENTS.md +++ b/docs/API_EVENTS.md @@ -88,7 +88,56 @@ The Events API provides access to **device event logs**, allowing creation, retr --- -### 4. Event Totals Over a Period +### 4. Get Recent Events + +* **GET** `/events/recent` → Get events from the last 24 hours +* **GET** `/events/` → Get events from the last N hours + +**Response** (JSON): + +```json +{ + "success": true, + "hours": 24, + "count": 5, + "events": [ + { + "eve_DateTime": "2025-12-07 12:00:00", + "eve_EventType": "New Device", + "eve_MAC": "AA:BB:CC:DD:EE:FF", + "eve_IP": "192.168.1.100", + "eve_AdditionalInfo": "Device detected" + } + ] +} +``` + +--- + +### 5. Get Latest Events + +* **GET** `/events/last` + Get the 10 most recent events. + +**Response** (JSON): + +```json +{ + "success": true, + "count": 10, + "events": [ + { + "eve_DateTime": "2025-12-07 12:00:00", + "eve_EventType": "Device Down", + "eve_MAC": "AA:BB:CC:DD:EE:FF" + } + ] +} +``` + +--- + +### 6. Event Totals Over a Period * **GET** `/sessions/totals?period=` Return event and session totals over a given period. @@ -116,12 +165,25 @@ The Events API provides access to **device event logs**, allowing creation, retr --- +## MCP Tools + +Event endpoints are available as **MCP Tools** for AI assistant integration: +- `get_recent_alerts`, `get_last_events` + +📖 See [MCP Server Bridge API](API_MCP.md) for AI integration details. + +--- + ## Notes -* All endpoints require **authorization** (Bearer token). Unauthorized requests return: +* All endpoints require **authorization** (Bearer token). Unauthorized requests return HTTP 403: ```json -{ "error": "Forbidden" } +{ + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" +} ``` * Events are stored in the **Events table** with the following fields: diff --git a/docs/API_MCP.md b/docs/API_MCP.md new file mode 100644 index 00000000..6ab19c4b --- /dev/null +++ b/docs/API_MCP.md @@ -0,0 +1,418 @@ +# MCP Server Bridge API + +The **MCP (Model Context Protocol) Server Bridge** provides AI assistants with standardized access to NetAlertX functionality through tools and server-sent events. This enables AI systems to interact with your network monitoring data in real-time. + +--- + +## Overview + +The MCP Server Bridge exposes NetAlertX functionality as **MCP Tools** that AI assistants can call to: + +- Search and retrieve device information +- Trigger network scans +- Get network topology and events +- Wake devices via Wake-on-LAN +- Access open port information +- Set device aliases + +All MCP endpoints mirror the functionality of standard REST endpoints but are optimized for AI assistant integration. + +--- + +## Architecture Overview + +### MCP Connection Flow + +```mermaid +graph TB + A[AI Assistant
Claude Desktop] -->|SSE Connection| B[NetAlertX MCP Server
:20212/mcp/sse] + B -->|JSON-RPC Messages| C[MCP Bridge
api_server_start.py] + C -->|Tool Calls| D[NetAlertX Tools
Device/Network APIs] + D -->|Response Data| C + C -->|JSON Response| B + B -->|Stream Events| A + + style A fill:#e1f5fe + style B fill:#f3e5f5 + style C fill:#fff3e0 + style D fill:#e8f5e8 +``` + +### MCP Tool Integration + +```mermaid +sequenceDiagram + participant AI as AI Assistant + participant MCP as MCP Server (:20212) + participant API as NetAlertX API (:20211) + participant DB as SQLite Database + + AI->>MCP: 1. Connect via SSE + MCP-->>AI: 2. Session established + AI->>MCP: 3. tools/list request + MCP->>API: 4. GET /mcp/sse/openapi.json + API-->>MCP: 5. Available tools spec + MCP-->>AI: 6. Tool definitions + AI->>MCP: 7. tools/call: search_devices + MCP->>API: 8. POST /mcp/sse/devices/search + API->>DB: 9. Query devices + DB-->>API: 10. Device data + API-->>MCP: 11. JSON response + MCP-->>AI: 12. Tool result +``` + +### Component Architecture + +```mermaid +graph LR + subgraph "AI Client" + A[Claude Desktop] + B[Custom MCP Client] + end + + subgraph "NetAlertX MCP Server (:20212)" + C[SSE Endpoint
/mcp/sse] + D[Message Handler
/mcp/messages] + E[OpenAPI Spec
/mcp/sse/openapi.json] + end + + subgraph "NetAlertX API Server (:20211)" + F[Device APIs
/mcp/sse/devices/*] + G[Network Tools
/mcp/sse/nettools/*] + H[Events API
/mcp/sse/events/*] + end + + subgraph "Backend" + I[SQLite Database] + J[Network Scanners] + K[Plugin System] + end + + A -.->|Bearer Auth| C + B -.->|Bearer Auth| C + C --> D + C --> E + D --> F + D --> G + D --> H + F --> I + G --> J + H --> I + + style A fill:#e1f5fe + style B fill:#e1f5fe + style C fill:#f3e5f5 + style D fill:#f3e5f5 + style E fill:#f3e5f5 + style F fill:#fff3e0 + style G fill:#fff3e0 + style H fill:#fff3e0 +``` + +--- + +## Authentication + +MCP endpoints use the same **Bearer token authentication** as REST endpoints: + +```http +Authorization: Bearer +``` + +Unauthorized requests return HTTP 403: + +```json +{ + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" +} +``` + +--- + +## MCP Connection Endpoint + +### Server-Sent Events (SSE) + +* **GET/POST** `/mcp/sse` + + Main MCP connection endpoint for AI clients. Establishes a persistent connection using Server-Sent Events for real-time communication between AI assistants and NetAlertX. + +**Connection Example**: + +```javascript +const eventSource = new EventSource('/mcp/sse', { + headers: { + 'Authorization': 'Bearer ' + } +}); + +eventSource.onmessage = function(event) { + const response = JSON.parse(event.data); + console.log('MCP Response:', response); +}; +``` + +--- + +## OpenAPI Specification + +### Get MCP Tools Specification + +* **GET** `/mcp/sse/openapi.json` + + Returns the OpenAPI specification for all available MCP tools, describing the parameters and schemas for each tool. + +**Response**: + +```json +{ + "openapi": "3.0.0", + "info": { + "title": "NetAlertX Tools", + "version": "1.1.0" + }, + "servers": [{"url": "/"}], + "paths": { + "/devices/by-status": { + "post": {"operationId": "list_devices"} + }, + "/device/{mac}": { + "post": {"operationId": "get_device_info"} + }, + "/devices/search": { + "post": {"operationId": "search_devices"} + } + } +} +``` + +--- + +## Available MCP Tools + +### Device Management Tools + +| Tool | Endpoint | Description | +|------|----------|-------------| +| `list_devices` | `/mcp/sse/devices/by-status` | List devices by online status | +| `get_device_info` | `/mcp/sse/device/` | Get detailed device information | +| `search_devices` | `/mcp/sse/devices/search` | Search devices by MAC, name, or IP | +| `get_latest_device` | `/mcp/sse/devices/latest` | Get most recently connected device | +| `set_device_alias` | `/mcp/sse/device//set-alias` | Set device friendly name | + +### Network Tools + +| Tool | Endpoint | Description | +|------|----------|-------------| +| `trigger_scan` | `/mcp/sse/nettools/trigger-scan` | Trigger network discovery scan | +| `get_open_ports` | `/mcp/sse/device/open_ports` | Get stored NMAP open ports for device | +| `wol_wake_device` | `/mcp/sse/nettools/wakeonlan` | Wake device using Wake-on-LAN | +| `get_network_topology` | `/mcp/sse/devices/network/topology` | Get network topology map | + +### Event & Monitoring Tools + +| Tool | Endpoint | Description | +|------|----------|-------------| +| `get_recent_alerts` | `/mcp/sse/events/recent` | Get events from last 24 hours | +| `get_last_events` | `/mcp/sse/events/last` | Get 10 most recent events | + +--- + +## Tool Usage Examples + +### Search Devices Tool + +**Tool Call**: +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "search_devices", + "arguments": { + "query": "192.168.1" + } + } +} +``` + +**Response**: +```json +{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"devices\": [\n {\n \"devName\": \"Router\",\n \"devMac\": \"AA:BB:CC:DD:EE:FF\",\n \"devLastIP\": \"192.168.1.1\"\n }\n ]\n}" + } + ], + "isError": false + } +} +``` + +### Trigger Network Scan Tool + +**Tool Call**: +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "tools/call", + "params": { + "name": "trigger_scan", + "arguments": { + "type": "ARPSCAN" + } + } +} +``` + +**Response**: +```json +{ + "jsonrpc": "2.0", + "id": "2", + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Scan triggered for type: ARPSCAN\"\n}" + } + ], + "isError": false + } +} +``` + +### Wake-on-LAN Tool + +**Tool Call**: +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "tools/call", + "params": { + "name": "wol_wake_device", + "arguments": { + "devMac": "AA:BB:CC:DD:EE:FF" + } + } +} +``` + +--- + +## Integration with AI Assistants + +### Claude Desktop Integration + +Add to your Claude Desktop `mcp.json` configuration: + +```json +{ + "mcp": { + "servers": { + "netalertx": { + "command": "node", + "args": ["/path/to/mcp-client.js"], + "env": { + "NETALERTX_URL": "http://your-server:", + "NETALERTX_TOKEN": "your-api-token" + } + } + } + } +} +``` + +### Generic MCP Client + +```python +import asyncio +import json +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +async def main(): + # Connect to NetAlertX MCP server + server_params = StdioServerParameters( + command="curl", + args=[ + "-N", "-H", "Authorization: Bearer ", + "http://your-server:/mcp/sse" + ] + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize connection + await session.initialize() + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Call a tool + result = await session.call_tool("search_devices", {"query": "router"}) + print(f"Search result: {result}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Error Handling + +MCP tool calls return structured error information: + +**Error Response**: +```json +{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "content": [ + { + "type": "text", + "text": "Error calling tool: Device not found" + } + ], + "isError": true + } +} +``` + +**Common Error Types**: +- `401/403` - Authentication failure +- `400` - Invalid parameters or missing required fields +- `404` - Resource not found (device, scan results, etc.) +- `500` - Internal server error + +--- + +## Notes + +* MCP endpoints require the same API token authentication as REST endpoints +* All MCP tools return JSON responses wrapped in MCP protocol format +* Server-Sent Events maintain persistent connections for real-time updates +* Tool parameters match their REST endpoint equivalents +* Error responses include both HTTP status codes and descriptive messages +* MCP bridge automatically handles request/response serialization + +--- + +## Related Documentation + +* [Main API Overview](API.md) - Core REST API documentation +* [Device API](API_DEVICE.md) - Individual device management +* [Devices Collection API](API_DEVICES.md) - Bulk device operations +* [Network Tools API](API_NETTOOLS.md) - Wake-on-LAN, scans, network utilities +* [Events API](API_EVENTS.md) - Event logging and monitoring \ No newline at end of file diff --git a/docs/API_NETTOOLS.md b/docs/API_NETTOOLS.md index 629ac984..ba71bbb0 100755 --- a/docs/API_NETTOOLS.md +++ b/docs/API_NETTOOLS.md @@ -241,3 +241,12 @@ curl -X POST "http://:/nettools/nmap" \ curl "http://:/nettools/internetinfo" \ -H "Authorization: Bearer " ``` + +--- + +## MCP Tools + +Network tools are available as **MCP Tools** for AI assistant integration: +- `wol_wake_device`, `trigger_scan`, `get_open_ports` + +📖 See [MCP Server Bridge API](API_MCP.md) for AI integration details. diff --git a/front/plugins/avahi_scan/avahi_scan.py b/front/plugins/avahi_scan/avahi_scan.py index 5c552181..c45a8b9e 100755 --- a/front/plugins/avahi_scan/avahi_scan.py +++ b/front/plugins/avahi_scan/avahi_scan.py @@ -12,7 +12,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -98,9 +97,7 @@ def main(): {"devMac": "00:11:22:33:44:57", "devLastIP": "192.168.1.82"}, ] else: - db = DB() - db.open() - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() devices = ( device_handler.getAll() if get_setting_value("REFRESH_FQDN") diff --git a/front/plugins/dig_scan/digscan.py b/front/plugins/dig_scan/digscan.py index 15280af2..1cb345e9 100755 --- a/front/plugins/dig_scan/digscan.py +++ b/front/plugins/dig_scan/digscan.py @@ -11,7 +11,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -38,15 +37,11 @@ def main(): timeout = get_setting_value('DIGSCAN_RUN_TIMEOUT') - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if get_setting_value("REFRESH_FQDN"): diff --git a/front/plugins/icmp_scan/icmp.py b/front/plugins/icmp_scan/icmp.py index 82544800..3e2a2664 100755 --- a/front/plugins/icmp_scan/icmp.py +++ b/front/plugins/icmp_scan/icmp.py @@ -15,7 +15,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -41,15 +40,11 @@ def main(): args = get_setting_value('ICMP_ARGS') in_regex = get_setting_value('ICMP_IN_REGEX') - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices all_devices = device_handler.getAll() diff --git a/front/plugins/nbtscan_scan/nbtscan.py b/front/plugins/nbtscan_scan/nbtscan.py index 689c093b..780aefd5 100755 --- a/front/plugins/nbtscan_scan/nbtscan.py +++ b/front/plugins/nbtscan_scan/nbtscan.py @@ -12,7 +12,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -40,15 +39,11 @@ def main(): # timeout = get_setting_value('NBLOOKUP_RUN_TIMEOUT') timeout = 20 - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if get_setting_value("REFRESH_FQDN"): diff --git a/front/plugins/nslookup_scan/nslookup.py b/front/plugins/nslookup_scan/nslookup.py index 8d9997ad..135ae14a 100755 --- a/front/plugins/nslookup_scan/nslookup.py +++ b/front/plugins/nslookup_scan/nslookup.py @@ -15,7 +15,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -39,15 +38,11 @@ def main(): timeout = get_setting_value('NSLOOKUP_RUN_TIMEOUT') - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if get_setting_value("REFRESH_FQDN"): diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py b/front/plugins/omada_sdn_imp/omada_sdn.py index ae429b01..8ee3ffea 100755 --- a/front/plugins/omada_sdn_imp/omada_sdn.py +++ b/front/plugins/omada_sdn_imp/omada_sdn.py @@ -256,13 +256,11 @@ def main(): start_time = time.time() mylog("verbose", [f"[{pluginName}] starting execution"]) - from database import DB + from models.device_instance import DeviceInstance - db = DB() # instance of class DB - db.open() # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve configuration settings # these should be self-explanatory omada_sites = [] diff --git a/front/plugins/wake_on_lan/wake_on_lan.py b/front/plugins/wake_on_lan/wake_on_lan.py index 4ef01e84..e65cbbed 100755 --- a/front/plugins/wake_on_lan/wake_on_lan.py +++ b/front/plugins/wake_on_lan/wake_on_lan.py @@ -13,7 +13,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] @@ -44,12 +43,8 @@ def main(): mylog('verbose', [f'[{pluginName}] broadcast_ips value {broadcast_ips}']) - # Create a database connection - db = DB() # instance of class DB - db.open() - # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if 'offline' in devices_to_wake: diff --git a/mkdocs.yml b/mkdocs.yml index ba00a943..d3516a8e 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,6 +98,7 @@ nav: - Sync: API_SYNC.md - GraphQL: API_GRAPHQL.md - DB query: API_DBQUERY.md + - MCP: API_MCP.md - Tests: API_TESTS.md - SUPERSEDED OLD API Overview: API_OLD.md - Integrations: diff --git a/requirements.txt b/requirements.txt index 70dc2282..af40c995 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +cryptography<40 openwrt-luci-rpc asusrouter aiohttp @@ -30,3 +31,4 @@ urllib3 httplib2 gunicorn git+https://github.com/foreign-sub/aiofreepybox.git +mcp diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index b44cd3c5..184adf93 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -3,11 +3,12 @@ import sys import os 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}/server"]) +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from logger import mylog # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] @@ -63,6 +64,12 @@ from .dbquery_endpoint import read_query, write_query, update_query, delete_quer from .sync_endpoint import handle_sync_post, handle_sync_get # noqa: E402 [flake8 lint suppression] from .logs_endpoint import clean_log # noqa: E402 [flake8 lint suppression] from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E402 [flake8 lint suppression] + +from 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, @@ -71,9 +78,17 @@ from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] delete_notification, mark_notification_as_read ) +from .mcp_endpoint import ( # noqa: E402 [flake8 lint suppression] + mcp_sse, + mcp_messages, + openapi_spec +) +# tools and mcp routes have been moved into this module (api_server_start) # Flask application app = Flask(__name__) + + CORS( app, resources={ @@ -88,22 +103,61 @@ CORS( r"/messaging/*": {"origins": "*"}, r"/events/*": {"origins": "*"}, r"/logs/*": {"origins": "*"}, - r"/auth/*": {"origins": "*"} + r"/api/tools/*": {"origins": "*"}, + r"/auth/*": {"origins": "*"}, + r"/mcp/*": {"origins": "*"} }, supports_credentials=True, allow_headers=["Authorization", "Content-Type"], ) +# ------------------------------------------------------------------------------- +# MCP bridge variables + helpers +# ------------------------------------------------------------------------------- + +BACKEND_PORT = get_setting_value("GRAPHQL_PORT") +API_BASE_URL = f"http://localhost:{BACKEND_PORT}" + + +@app.route('/mcp/sse', methods=['GET', 'POST']) +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']) +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 {error.description if hasattr(error, 'description') else ''} was not found on the server.", + "message": f"The requested URL {requested_url} was not found on the server.", } return jsonify(response), 404 @@ -126,7 +180,7 @@ def graphql_endpoint(): if not is_authorized(): msg = '[graphql_server] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct.' mylog('verbose', [msg]) - return jsonify({"success": False, "message": msg, "error": "Forbidden"}), 401 + return jsonify({"success": False, "message": msg, "error": "Forbidden"}), 403 # Retrieve and log request data data = request.get_json() @@ -146,11 +200,12 @@ def graphql_endpoint(): return jsonify(response) +# Tools endpoints are registered via `mcp_endpoint.tools_bp` blueprint. + + # -------------------------- # Settings Endpoints # -------------------------- - - @app.route("/settings/", methods=["GET"]) def api_get_setting(setKey): if not is_authorized(): @@ -162,8 +217,7 @@ def api_get_setting(setKey): # -------------------------- # Device Endpoints # -------------------------- - - +@app.route('/mcp/sse/device/', methods=['GET', 'POST']) @app.route("/device/", methods=["GET"]) def api_get_device(mac): if not is_authorized(): @@ -229,11 +283,45 @@ def api_update_device_column(mac): return update_device_column(mac, column_name, column_value) +@app.route('/mcp/sse/device//set-alias', methods=['POST']) +@app.route('/device//set-alias', methods=['POST']) +def api_device_set_alias(mac): + """Set the device alias - convenience wrapper around update_device_column.""" + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + data = request.get_json() or {} + alias = data.get('alias') + if not alias: + return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "alias is required"}), 400 + return update_device_column(mac, 'devName', alias) + + +@app.route('/mcp/sse/device/open_ports', methods=['POST']) +@app.route('/device/open_ports', methods=['POST']) +def api_device_open_ports(): + """Get stored NMAP open ports for a target IP or MAC.""" + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + data = request.get_json(silent=True) or {} + target = data.get('target') + if not target: + return jsonify({"success": False, "error": "Target (IP or MAC) is required"}), 400 + + device_handler = DeviceInstance() + + # Use DeviceInstance method to get stored open ports + open_ports = device_handler.getOpenPorts(target) + + if not open_ports: + return jsonify({"success": False, "error": f"No stored open ports for {target}. Run a scan with `/nettools/trigger-scan`"}), 404 + + return jsonify({"success": True, "target": target, "open_ports": open_ports}) + + # -------------------------- # Devices Collections # -------------------------- - - @app.route("/devices", methods=["GET"]) def api_get_devices(): if not is_authorized(): @@ -289,6 +377,7 @@ def api_devices_totals(): return devices_totals() +@app.route('/mcp/sse/devices/by-status', methods=['GET', 'POST']) @app.route("/devices/by-status", methods=["GET"]) def api_devices_by_status(): if not is_authorized(): @@ -299,15 +388,93 @@ def api_devices_by_status(): return devices_by_status(status) +@app.route('/mcp/sse/devices/search', methods=['POST']) +@app.route('/devices/search', methods=['POST']) +def api_devices_search(): + """Device search: accepts 'query' in JSON and maps to device info/search.""" + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + 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 + + if is_mac(query): + device_data = get_device_data(query) + if device_data.status_code == 200: + return jsonify({"success": True, "devices": [device_data.get_json()]}) + else: + return jsonify({"success": False, "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: + 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']) +def api_devices_latest(): + """Get latest device (most recent) - maps to DeviceInstance.getLatest().""" + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + device_handler = DeviceInstance() + + latest = device_handler.getLatest() + + if not latest: + return jsonify({"message": "No devices found"}), 404 + return jsonify([latest]) + + +@app.route('/mcp/sse/devices/network/topology', methods=['GET']) +@app.route('/devices/network/topology', methods=['GET']) +def api_devices_network_topology(): + """Network topology mapping.""" + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + device_handler = DeviceInstance() + + result = device_handler.getNetworkTopology() + + return jsonify(result) + + # -------------------------- # Net tools # -------------------------- +@app.route('/mcp/sse/nettools/wakeonlan', methods=['POST']) @app.route("/nettools/wakeonlan", methods=["POST"]) def api_wakeonlan(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - mac = request.json.get("devMac") + data = request.json or {} + mac = data.get("devMac") + ip = data.get("devLastIP") or data.get('ip') + if not mac and ip: + + device_handler = DeviceInstance() + + dev = device_handler.getByIP(ip) + + if not dev or not dev.get('devMac'): + return jsonify({"success": False, "message": "ERROR: Device not found", "error": "MAC not resolved"}), 404 + mac = dev.get('devMac') + + # 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) @@ -368,11 +535,42 @@ def api_internet_info(): return internet_info() +@app.route('/mcp/sse/nettools/trigger-scan', methods=['POST']) +@app.route("/nettools/trigger-scan", methods=["GET"]) +def api_trigger_scan(): + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + data = request.get_json(silent=True) or {} + scan_type = data.get('type', 'ARPSCAN') + + # Validate scan type + loaded_plugins = get_setting_value('LOADED_PLUGINS') + if scan_type not in loaded_plugins: + return jsonify({"success": False, "error": f"Invalid scan type. Must be one of: {', '.join(loaded_plugins)}"}), 400 + + queue = UserEventsQueueInstance() + + action = f"run|{scan_type}" + + queue.add_event(action) + + return jsonify({"success": True, "message": f"Scan triggered for type: {scan_type}"}), 200 + + +# -------------------------- +# MCP Server +# -------------------------- +@app.route('/mcp/sse/openapi.json', methods=['GET']) +def api_openapi_spec(): + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + return openapi_spec() + + # -------------------------- # DB query # -------------------------- - - @app.route("/dbquery/read", methods=["POST"]) def dbquery_read(): if not is_authorized(): @@ -395,6 +593,7 @@ def dbquery_write(): data = request.get_json() or {} raw_sql_b64 = data.get("rawSql") if not raw_sql_b64: + return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "rawSql is required"}), 400 return write_query(raw_sql_b64) @@ -460,11 +659,13 @@ def api_delete_online_history(): @app.route("/logs", methods=["DELETE"]) def api_clean_log(): + if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 file = request.args.get("file") if not file: + return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing 'file' query parameter"}), 400 return clean_log(file) @@ -499,8 +700,6 @@ def api_add_to_execution_queue(): # -------------------------- # Device Events # -------------------------- - - @app.route("/events/create/", methods=["POST"]) def api_create_event(mac): if not is_authorized(): @@ -564,6 +763,45 @@ def api_get_events_totals(): return get_events_totals(period) +@app.route('/mcp/sse/events/recent', methods=['GET', 'POST']) +@app.route('/events/recent', methods=['GET']) +def api_events_default_24h(): + return api_events_recent(24) # Reuse handler + + +@app.route('/mcp/sse/events/last', methods=['GET', 'POST']) +@app.route('/events/last', methods=['GET']) +def get_last_events(): + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + # Create fresh DB instance for this thread + event_handler = EventInstance() + + events = event_handler.get_last_n(10) + return jsonify({"success": True, "count": len(events), "events": events}), 200 + + +@app.route('/events/', methods=['GET']) +def api_events_recent(hours): + """Return events from the last hours using EventInstance.""" + + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + # Validate hours input + if hours <= 0: + return jsonify({"success": False, "error": "Hours must be > 0"}), 400 + try: + # Create fresh DB instance for this thread + event_handler = EventInstance() + + events = event_handler.get_by_hours(hours) + + return jsonify({"success": True, "hours": hours, "count": len(events), "events": events}), 200 + + except Exception as ex: + return jsonify({"success": False, "error": str(ex)}), 500 + # -------------------------- # Sessions # -------------------------- @@ -793,3 +1031,9 @@ def start_server(graphql_port, app_state): # Update the state to indicate the server has started app_state = updateState("Process: Idle", None, None, None, 1) + + +if __name__ == "__main__": + # This block is for running the server directly for testing purposes + # In production, start_server is called from api.py + pass diff --git a/server/api_server/devices_endpoint.py b/server/api_server/devices_endpoint.py index e924aec4..2e850d5e 100755 --- a/server/api_server/devices_endpoint.py +++ b/server/api_server/devices_endpoint.py @@ -228,7 +228,8 @@ def devices_totals(): def devices_by_status(status=None): """ - Return devices filtered by status. + Return devices filtered by status. Returns all if no status provided. + Possible statuses: my, connected, favorites, new, down, archived """ conn = get_temp_db_connection() diff --git a/server/api_server/mcp_endpoint.py b/server/api_server/mcp_endpoint.py new file mode 100644 index 00000000..130324c8 --- /dev/null +++ b/server/api_server/mcp_endpoint.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python + +import threading +from flask import Blueprint, request, jsonify, Response, stream_with_context +from helper import get_setting_value +from helper import mylog +# from .events_endpoint import get_events # will import locally where needed +import requests +import json +import uuid +import queue + +# Blueprints +mcp_bp = Blueprint('mcp', __name__) +tools_bp = Blueprint('tools', __name__) + +mcp_sessions = {} +mcp_sessions_lock = threading.Lock() + + +def check_auth(): + token = request.headers.get("Authorization") + expected_token = f"Bearer {get_setting_value('API_TOKEN')}" + return token == expected_token + + +# -------------------------- +# Specs +# -------------------------- +def openapi_spec(): + # Spec matching actual available routes for MCP tools + mylog("verbose", ["[MCP] OpenAPI spec requested"]) + spec = { + "openapi": "3.0.0", + "info": {"title": "NetAlertX Tools", "version": "1.1.0"}, + "servers": [{"url": "/"}], + "paths": { + "/devices/by-status": {"post": {"operationId": "list_devices"}}, + "/device/{mac}": {"post": {"operationId": "get_device_info"}}, + "/devices/search": {"post": {"operationId": "search_devices"}}, + "/devices/latest": {"get": {"operationId": "get_latest_device"}}, + "/nettools/trigger-scan": {"post": {"operationId": "trigger_scan"}}, + "/device/open_ports": {"post": {"operationId": "get_open_ports"}}, + "/devices/network/topology": {"get": {"operationId": "get_network_topology"}}, + "/events/recent": {"get": {"operationId": "get_recent_alerts"}, "post": {"operationId": "get_recent_alerts"}}, + "/events/last": {"get": {"operationId": "get_last_events"}, "post": {"operationId": "get_last_events"}}, + "/device/{mac}/set-alias": {"post": {"operationId": "set_device_alias"}}, + "/nettools/wakeonlan": {"post": {"operationId": "wol_wake_device"}} + } + } + return jsonify(spec) + + +# -------------------------- +# MCP SSE/JSON-RPC Endpoint +# -------------------------- + + +# Sessions for SSE +_openapi_spec_cache = None +API_BASE_URL = f"http://localhost:{get_setting_value('GRAPHQL_PORT')}" + + +def get_openapi_spec(): + global _openapi_spec_cache + + if _openapi_spec_cache: + return _openapi_spec_cache + try: + # Call the openapi_spec function directly instead of making HTTP request + # to avoid circular requests and authorization issues + response = openapi_spec() + _openapi_spec_cache = response.get_json() + return _openapi_spec_cache + except Exception as e: + mylog("none", [f"[MCP] Failed to fetch OpenAPI spec: {e}"]) + return None + + +def map_openapi_to_mcp_tools(spec): + tools = [] + if not spec or 'paths' not in spec: + return tools + for path, methods in spec['paths'].items(): + for method, details in methods.items(): + if 'operationId' in details: + tool = {'name': details['operationId'], 'description': details.get('description', ''), 'inputSchema': {'type': 'object', 'properties': {}, 'required': []}} + if 'requestBody' in details: + content = details['requestBody'].get('content', {}) + if 'application/json' in content: + schema = content['application/json'].get('schema', {}) + tool['inputSchema'] = schema.copy() + if 'parameters' in details: + for param in details['parameters']: + if param.get('in') == 'query': + tool['inputSchema']['properties'][param['name']] = {'type': param.get('schema', {}).get('type', 'string'), 'description': param.get('description', '')} + if param.get('required'): + tool['inputSchema']['required'].append(param['name']) + tools.append(tool) + return tools + + +def process_mcp_request(data): + method = data.get('method') + msg_id = data.get('id') + if method == 'initialize': + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'NetAlertX', 'version': '1.0.0'}}} + if method == 'notifications/initialized': + return None + if method == 'tools/list': + spec = get_openapi_spec() + tools = map_openapi_to_mcp_tools(spec) + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'tools': tools}} + if method == 'tools/call': + params = data.get('params', {}) + tool_name = params.get('name') + tool_args = params.get('arguments', {}) + spec = get_openapi_spec() + target_path = None + target_method = None + if spec and 'paths' in spec: + for path, methods in spec['paths'].items(): + for m, details in methods.items(): + if details.get('operationId') == tool_name: + target_path = path + target_method = m.upper() + break + if target_path: + break + if not target_path: + return {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': f"Tool {tool_name} not found"}} + try: + headers = {'Content-Type': 'application/json'} + if 'Authorization' in request.headers: + headers['Authorization'] = request.headers['Authorization'] + url = f"{API_BASE_URL}{target_path}" + if target_method == 'POST': + api_res = requests.post(url, json=tool_args, headers=headers, timeout=30) + else: + api_res = requests.get(url, params=tool_args, headers=headers, timeout=30) + content = [] + try: + json_content = api_res.json() + content.append({'type': 'text', 'text': json.dumps(json_content, indent=2)}) + except Exception as e: + mylog("none", [f"[MCP] Failed to parse API response as JSON: {e}"]) + content.append({'type': 'text', 'text': api_res.text}) + is_error = api_res.status_code >= 400 + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': content, 'isError': is_error}} + except Exception as e: + mylog("none", [f"[MCP] Error calling tool {tool_name}: {e}"]) + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': [{'type': 'text', 'text': f"Error calling tool: {str(e)}"}], 'isError': True}} + if method == 'ping': + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {}} + if msg_id: + return {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': 'Method not found'}} + + +def mcp_messages(): + session_id = request.args.get('session_id') + if not session_id: + return jsonify({"error": "Missing session_id"}), 400 + with mcp_sessions_lock: + if session_id not in mcp_sessions: + return jsonify({"error": "Session not found"}), 404 + q = mcp_sessions[session_id] + data = request.json + if not data: + return jsonify({"error": "Invalid JSON"}), 400 + response = process_mcp_request(data) + if response: + q.put(response) + return jsonify({"status": "accepted"}), 202 + + +def mcp_sse(): + if request.method == 'POST': + try: + data = request.get_json(silent=True) + if data and 'method' in data and 'jsonrpc' in data: + response = process_mcp_request(data) + if response: + return jsonify(response) + else: + return '', 202 + except Exception as e: + mylog("none", [f"[MCP] SSE POST processing error: {e}"]) + return jsonify({'status': 'ok', 'message': 'MCP SSE endpoint active'}), 200 + + session_id = uuid.uuid4().hex + q = queue.Queue() + with mcp_sessions_lock: + mcp_sessions[session_id] = q + + def stream(): + yield f"event: endpoint\ndata: /mcp/messages?session_id={session_id}\n\n" + try: + while True: + try: + message = q.get(timeout=20) + yield f"event: message\ndata: {json.dumps(message)}\n\n" + except queue.Empty: + yield ": keep-alive\n\n" + except GeneratorExit: + with mcp_sessions_lock: + if session_id in mcp_sessions: + del mcp_sessions[session_id] + return Response(stream_with_context(stream()), mimetype='text/event-stream') diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 795950bf..97ee4400 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -1,83 +1,134 @@ +from front.plugins.plugin_helper import is_mac from logger import mylog +from models.plugin_object_instance import PluginObjectInstance +from database import get_temp_db_connection -# ------------------------------------------------------------------------------- -# Device object handling (WIP) -# ------------------------------------------------------------------------------- class DeviceInstance: - def __init__(self, db): - self.db = db - # Get all + # --- helpers -------------------------------------------------------------- + def _fetchall(self, query, params=()): + conn = get_temp_db_connection() + rows = conn.execute(query, params).fetchall() + conn.close() + return [dict(r) for r in rows] + + def _fetchone(self, query, params=()): + conn = get_temp_db_connection() + row = conn.execute(query, params).fetchone() + conn.close() + return dict(row) if row else None + + def _execute(self, query, params=()): + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute(query, params) + conn.commit() + conn.close() + + # --- public API ----------------------------------------------------------- def getAll(self): - self.db.sql.execute(""" - SELECT * FROM Devices - """) - return self.db.sql.fetchall() + return self._fetchall("SELECT * FROM Devices") - # Get all with unknown names def getUnknown(self): - self.db.sql.execute(""" - SELECT * FROM Devices WHERE devName in ("(unknown)", "(name not found)", "" ) + return self._fetchall(""" + SELECT * FROM Devices + WHERE devName IN ("(unknown)", "(name not found)", "") """) - return self.db.sql.fetchall() - # Get specific column value based on devMac def getValueWithMac(self, column_name, devMac): - query = f"SELECT {column_name} FROM Devices WHERE devMac = ?" - self.db.sql.execute(query, (devMac,)) - result = self.db.sql.fetchone() - return result[column_name] if result else None + row = self._fetchone(f""" + SELECT {column_name} FROM Devices WHERE devMac = ? + """, (devMac,)) + return row.get(column_name) if row else None - # Get all down def getDown(self): - self.db.sql.execute(""" - SELECT * FROM Devices WHERE devAlertDown = 1 and devPresentLastScan = 0 + return self._fetchall(""" + SELECT * FROM Devices + WHERE devAlertDown = 1 AND devPresentLastScan = 0 """) - return self.db.sql.fetchall() - # Get all down def getOffline(self): - self.db.sql.execute(""" - SELECT * FROM Devices WHERE devPresentLastScan = 0 + return self._fetchall(""" + SELECT * FROM Devices + WHERE devPresentLastScan = 0 """) - return self.db.sql.fetchall() - # Get a device by devGUID def getByGUID(self, devGUID): - self.db.sql.execute("SELECT * FROM Devices WHERE devGUID = ?", (devGUID,)) - result = self.db.sql.fetchone() - return dict(result) if result else None + return self._fetchone(""" + SELECT * FROM Devices WHERE devGUID = ? + """, (devGUID,)) - # Check if a device exists by devGUID def exists(self, devGUID): - self.db.sql.execute( - "SELECT COUNT(*) AS count FROM Devices WHERE devGUID = ?", (devGUID,) - ) - result = self.db.sql.fetchone() - return result["count"] > 0 + row = self._fetchone(""" + SELECT COUNT(*) as count FROM Devices WHERE devGUID = ? + """, (devGUID,)) + return row['count'] > 0 if row else False + + def getByIP(self, ip): + return self._fetchone(""" + SELECT * FROM Devices WHERE devLastIP = ? + """, (ip,)) + + def search(self, query): + like = f"%{query}%" + return self._fetchall(""" + SELECT * FROM Devices + WHERE devMac LIKE ? OR devName LIKE ? OR devLastIP LIKE ? + """, (like, like, like)) + + def getLatest(self): + return self._fetchone(""" + SELECT * FROM Devices + ORDER BY devFirstConnection DESC LIMIT 1 + """) + + def getNetworkTopology(self): + rows = self._fetchall(""" + SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices + """) + nodes = [{"id": r["devMac"], "name": r["devName"], "vendor": r["devVendor"]} for r in rows] + links = [{"source": r["devParentMAC"], "target": r["devMac"], "port": r["devParentPort"]} + for r in rows if r["devParentMAC"]] + return {"nodes": nodes, "links": links} - # Update a specific field for a device def updateField(self, devGUID, field, value): if not self.exists(devGUID): - m = f"[Device] In 'updateField': GUID {devGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[Device] updateField: GUID {devGUID} not found" + mylog("none", msg) + raise ValueError(msg) + self._execute(f"UPDATE Devices SET {field}=? WHERE devGUID=?", (value, devGUID)) - self.db.sql.execute( - f""" - UPDATE Devices SET {field} = ? WHERE devGUID = ? - """, - (value, devGUID), - ) - self.db.commitDB() - - # Delete a device by devGUID def delete(self, devGUID): if not self.exists(devGUID): - m = f"[Device] In 'delete': GUID {devGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[Device] delete: GUID {devGUID} not found" + mylog("none", msg) + raise ValueError(msg) + self._execute("DELETE FROM Devices WHERE devGUID=?", (devGUID,)) - self.db.sql.execute("DELETE FROM Devices WHERE devGUID = ?", (devGUID,)) - self.db.commitDB() + def resolvePrimaryID(self, target): + if is_mac(target): + return target.lower() + dev = self.getByIP(target) + return dev['devMac'].lower() if dev else None + + def getOpenPorts(self, target): + primary = self.resolvePrimaryID(target) + if not primary: + return [] + + objs = PluginObjectInstance().getByField( + plugPrefix='NMAP', + matchedColumn='Object_PrimaryID', + matchedKey=primary, + returnFields=['Object_SecondaryID', 'Watched_Value2'] + ) + + ports = [] + for o in objs: + + port = int(o.get('Object_SecondaryID') or 0) + + ports.append({"port": port, "service": o.get('Watched_Value2', '')}) + + return ports diff --git a/server/models/event_instance.py b/server/models/event_instance.py new file mode 100644 index 00000000..bdb5960f --- /dev/null +++ b/server/models/event_instance.py @@ -0,0 +1,107 @@ +from datetime import datetime, timedelta +from logger import mylog +from database import get_temp_db_connection + + +# ------------------------------------------------------------------------------- +# Event handling (Matches table: Events) +# ------------------------------------------------------------------------------- +class EventInstance: + + def _conn(self): + """Always return a new DB connection (thread-safe).""" + return get_temp_db_connection() + + def _rows_to_list(self, rows): + return [dict(r) for r in rows] + + # Get all events + def get_all(self): + conn = self._conn() + rows = conn.execute( + "SELECT * FROM Events ORDER BY eve_DateTime DESC" + ).fetchall() + conn.close() + return self._rows_to_list(rows) + + # --- Get last n events --- + def get_last_n(self, n=10): + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + ORDER BY eve_DateTime DESC + LIMIT ? + """, (n,)).fetchall() + conn.close() + return self._rows_to_list(rows) + + # --- Specific helper for last 10 --- + def get_last(self): + return self.get_last_n(10) + + # Get events in the last 24h + def get_recent(self): + since = datetime.now() - timedelta(hours=24) + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + WHERE eve_DateTime >= ? + ORDER BY eve_DateTime DESC + """, (since,)).fetchall() + conn.close() + return self._rows_to_list(rows) + + # Get events from last N hours + def get_by_hours(self, hours: int): + if hours <= 0: + mylog("warn", f"[Events] get_by_hours({hours}) -> invalid value") + return [] + + since = datetime.now() - timedelta(hours=hours) + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + WHERE eve_DateTime >= ? + ORDER BY eve_DateTime DESC + """, (since,)).fetchall() + conn.close() + return self._rows_to_list(rows) + + # Get events in a date range + def get_by_range(self, start: datetime, end: datetime): + if end < start: + mylog("error", f"[Events] get_by_range invalid: {start} > {end}") + raise ValueError("Start must not be after end") + + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + WHERE eve_DateTime BETWEEN ? AND ? + ORDER BY eve_DateTime DESC + """, (start, end)).fetchall() + conn.close() + return self._rows_to_list(rows) + + # Insert new event + def add(self, mac, ip, eventType, info="", pendingAlert=True, pairRow=None): + conn = self._conn() + conn.execute(""" + INSERT INTO Events ( + eve_MAC, eve_IP, eve_DateTime, + eve_EventType, eve_AdditionalInfo, + eve_PendingAlertEmail, eve_PairEventRowid + ) VALUES (?,?,?,?,?,?,?) + """, (mac, ip, datetime.now(), eventType, info, + 1 if pendingAlert else 0, pairRow)) + conn.commit() + conn.close() + + # Delete old events + def delete_older_than(self, days: int): + cutoff = datetime.now() - timedelta(days=days) + conn = self._conn() + result = conn.execute("DELETE FROM Events WHERE eve_DateTime < ?", (cutoff,)) + conn.commit() + deleted_count = result.rowcount + conn.close() + return deleted_count diff --git a/server/models/plugin_object_instance.py b/server/models/plugin_object_instance.py index 2adaaa6f..3d4ceaf2 100755 --- a/server/models/plugin_object_instance.py +++ b/server/models/plugin_object_instance.py @@ -1,70 +1,91 @@ from logger import mylog +from database import get_temp_db_connection # ------------------------------------------------------------------------------- -# Plugin object handling (WIP) +# Plugin object handling (THREAD-SAFE REWRITE) # ------------------------------------------------------------------------------- class PluginObjectInstance: - def __init__(self, db): - self.db = db - # Get all plugin objects + # -------------- Internal DB helper wrappers -------------------------------- + def _fetchall(self, query, params=()): + conn = get_temp_db_connection() + rows = conn.execute(query, params).fetchall() + conn.close() + return [dict(r) for r in rows] + + def _fetchone(self, query, params=()): + conn = get_temp_db_connection() + row = conn.execute(query, params).fetchone() + conn.close() + return dict(row) if row else None + + def _execute(self, query, params=()): + conn = get_temp_db_connection() + conn.execute(query, params) + conn.commit() + conn.close() + + # --------------------------------------------------------------------------- + # Public API — identical behaviour, now thread-safe + self-contained + # --------------------------------------------------------------------------- + def getAll(self): - self.db.sql.execute(""" - SELECT * FROM Plugins_Objects - """) - return self.db.sql.fetchall() + return self._fetchall("SELECT * FROM Plugins_Objects") - # Get plugin object by ObjectGUID def getByGUID(self, ObjectGUID): - self.db.sql.execute( + return self._fetchone( "SELECT * FROM Plugins_Objects WHERE ObjectGUID = ?", (ObjectGUID,) ) - result = self.db.sql.fetchone() - return dict(result) if result else None - # Check if a plugin object exists by ObjectGUID def exists(self, ObjectGUID): - self.db.sql.execute( - "SELECT COUNT(*) AS count FROM Plugins_Objects WHERE ObjectGUID = ?", - (ObjectGUID,), - ) - result = self.db.sql.fetchone() - return result["count"] > 0 + row = self._fetchone(""" + SELECT COUNT(*) AS count FROM Plugins_Objects WHERE ObjectGUID = ? + """, (ObjectGUID,)) + return row["count"] > 0 if row else False - # Get objects by plugin name def getByPlugin(self, plugin): - self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,)) - return self.db.sql.fetchall() + return self._fetchall( + "SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,) + ) + + def getByField(self, plugPrefix, matchedColumn, matchedKey, returnFields=None): + rows = self._fetchall( + f"SELECT * FROM Plugins_Objects WHERE Plugin = ? AND {matchedColumn} = ?", + (plugPrefix, matchedKey.lower()) + ) + + if not returnFields: + return rows + + return [{f: row.get(f) for f in returnFields} for row in rows] + + def getByPrimary(self, plugin, primary_id): + return self._fetchall(""" + SELECT * FROM Plugins_Objects + WHERE Plugin = ? AND Object_PrimaryID = ? + """, (plugin, primary_id)) - # Get objects by status def getByStatus(self, status): - self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Status = ?", (status,)) - return self.db.sql.fetchall() + return self._fetchall(""" + SELECT * FROM Plugins_Objects WHERE Status = ? + """, (status,)) - # Update a specific field for a plugin object def updateField(self, ObjectGUID, field, value): if not self.exists(ObjectGUID): - m = f"[PluginObject] In 'updateField': GUID {ObjectGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[PluginObject] updateField: GUID {ObjectGUID} not found." + mylog("none", msg) + raise ValueError(msg) - self.db.sql.execute( - f""" - UPDATE Plugins_Objects SET {field} = ? WHERE ObjectGUID = ? - """, - (value, ObjectGUID), + self._execute( + f"UPDATE Plugins_Objects SET {field}=? WHERE ObjectGUID=?", + (value, ObjectGUID) ) - self.db.commitDB() - # Delete a plugin object by ObjectGUID def delete(self, ObjectGUID): if not self.exists(ObjectGUID): - m = f"[PluginObject] In 'delete': GUID {ObjectGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[PluginObject] delete: GUID {ObjectGUID} not found." + mylog("none", msg) + raise ValueError(msg) - self.db.sql.execute( - "DELETE FROM Plugins_Objects WHERE ObjectGUID = ?", (ObjectGUID,) - ) - self.db.commitDB() + self._execute("DELETE FROM Plugins_Objects WHERE ObjectGUID=?", (ObjectGUID,)) diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index cf396898..ec767f29 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -650,7 +650,7 @@ def update_devices_names(pm): sql = pm.db.sql resolver = NameResolver(pm.db) - device_handler = DeviceInstance(pm.db) + device_handler = DeviceInstance() nameNotFound = "(name not found)" diff --git a/server/workflows/actions.py b/server/workflows/actions.py index 3df87cb4..da90aced 100755 --- a/server/workflows/actions.py +++ b/server/workflows/actions.py @@ -42,13 +42,13 @@ class UpdateFieldAction(Action): # currently unused if isinstance(obj, dict) and "ObjectGUID" in obj: mylog("debug", f"[WF] Updating Object '{obj}' ") - plugin_instance = PluginObjectInstance(self.db) + plugin_instance = PluginObjectInstance() plugin_instance.updateField(obj["ObjectGUID"], self.field, self.value) processed = True elif isinstance(obj, dict) and "devGUID" in obj: mylog("debug", f"[WF] Updating Device '{obj}' ") - device_instance = DeviceInstance(self.db) + device_instance = DeviceInstance() device_instance.updateField(obj["devGUID"], self.field, self.value) processed = True @@ -79,13 +79,13 @@ class DeleteObjectAction(Action): # currently unused if isinstance(obj, dict) and "ObjectGUID" in obj: mylog("debug", f"[WF] Updating Object '{obj}' ") - plugin_instance = PluginObjectInstance(self.db) + plugin_instance = PluginObjectInstance() plugin_instance.delete(obj["ObjectGUID"]) processed = True elif isinstance(obj, dict) and "devGUID" in obj: mylog("debug", f"[WF] Updating Device '{obj}' ") - device_instance = DeviceInstance(self.db) + device_instance = DeviceInstance() device_instance.delete(obj["devGUID"]) processed = True diff --git a/test/api_endpoints/test_graphq_endpoints.py b/test/api_endpoints/test_graphq_endpoints.py index 26255ffb..d09c9ea3 100644 --- a/test/api_endpoints/test_graphq_endpoints.py +++ b/test/api_endpoints/test_graphq_endpoints.py @@ -38,10 +38,10 @@ def test_graphql_debug_get(client): def test_graphql_post_unauthorized(client): - """POST /graphql without token should return 401""" + """POST /graphql without token should return 403""" query = {"query": "{ devices { devName devMac } }"} resp = client.post("/graphql", json=query) - assert resp.status_code == 401 + assert resp.status_code == 403 assert "Unauthorized access attempt" in resp.json.get("message", "") assert "Forbidden" in resp.json.get("error", "") diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py new file mode 100644 index 00000000..92329ba1 --- /dev/null +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -0,0 +1,306 @@ +import sys +import os +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime + +INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from helper import get_setting_value # noqa: E402 +from api_server.api_server_start import app # noqa: E402 + + +@pytest.fixture(scope="session") +def api_token(): + return get_setting_value("API_TOKEN") + + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + + +def auth_headers(token): + return {"Authorization": f"Bearer {token}"} + + +# --- Device Search Tests --- + +@patch('models.device_instance.get_temp_db_connection') +def test_get_device_info_ip_partial(mock_db_conn, client, api_token): + """Test device search with partial IP search.""" + # Mock database connection - DeviceInstance._fetchall calls conn.execute().fetchall() + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchall.return_value = [ + {"devName": "Test Device", "devMac": "AA:BB:CC:DD:EE:FF", "devLastIP": "192.168.1.50"} + ] + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn + + payload = {"query": ".50"} + response = client.post('/devices/search', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert len(data["devices"]) == 1 + assert data["devices"][0]["devLastIP"] == "192.168.1.50" + + +# --- Trigger Scan Tests --- + +@patch('api_server.api_server_start.UserEventsQueueInstance') +def test_trigger_scan_ARPSCAN(mock_queue_class, client, api_token): + """Test trigger_scan with ARPSCAN type.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + payload = {"type": "ARPSCAN"} + response = client.post('/mcp/sse/nettools/trigger-scan', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + mock_queue.add_event.assert_called_once() + call_args = mock_queue.add_event.call_args[0] + assert "run|ARPSCAN" in call_args[0] + + +@patch('api_server.api_server_start.UserEventsQueueInstance') +def test_trigger_scan_invalid_type(mock_queue_class, client, api_token): + """Test trigger_scan with invalid scan type.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + payload = {"type": "invalid_type", "target": "192.168.1.0/24"} + response = client.post('/mcp/sse/nettools/trigger-scan', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + +# --- get_open_ports Tests --- + + +@patch('models.plugin_object_instance.get_temp_db_connection') +@patch('models.device_instance.get_temp_db_connection') +def test_get_open_ports_ip(mock_plugin_db_conn, mock_device_db_conn, client, api_token): + """Test get_open_ports with an IP address.""" + # Mock database connections for both device lookup and plugin objects + mock_conn = MagicMock() + mock_execute_result = MagicMock() + + # Mock for PluginObjectInstance.getByField (returns port data) + mock_execute_result.fetchall.return_value = [ + {"Object_SecondaryID": "22", "Watched_Value2": "ssh"}, + {"Object_SecondaryID": "80", "Watched_Value2": "http"} + ] + # Mock for DeviceInstance.getByIP (returns device with MAC) + mock_execute_result.fetchone.return_value = {"devMac": "AA:BB:CC:DD:EE:FF"} + + mock_conn.execute.return_value = mock_execute_result + mock_plugin_db_conn.return_value = mock_conn + mock_device_db_conn.return_value = mock_conn + + payload = {"target": "192.168.1.1"} + response = client.post('/device/open_ports', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert len(data["open_ports"]) == 2 + assert data["open_ports"][0]["port"] == 22 + assert data["open_ports"][1]["service"] == "http" + + +@patch('models.plugin_object_instance.get_temp_db_connection') +def test_get_open_ports_mac_resolve(mock_plugin_db_conn, client, api_token): + """Test get_open_ports with a MAC address that resolves to an IP.""" + # Mock database connection for MAC-based open ports query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchall.return_value = [ + {"Object_SecondaryID": "80", "Watched_Value2": "http"} + ] + mock_conn.execute.return_value = mock_execute_result + mock_plugin_db_conn.return_value = mock_conn + + payload = {"target": "AA:BB:CC:DD:EE:FF"} + response = client.post('/device/open_ports', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "target" in data + assert len(data["open_ports"]) == 1 + assert data["open_ports"][0]["port"] == 80 + + +# --- get_network_topology Tests --- +@patch('models.device_instance.get_temp_db_connection') +def test_get_network_topology(mock_db_conn, client, api_token): + """Test get_network_topology.""" + # Mock database connection for topology query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchall.return_value = [ + {"devName": "Router", "devMac": "AA:AA:AA:AA:AA:AA", "devParentMAC": None, "devParentPort": None, "devVendor": "VendorA"}, + {"devName": "Device1", "devMac": "BB:BB:BB:BB:BB:BB", "devParentMAC": "AA:AA:AA:AA:AA:AA", "devParentPort": "eth1", "devVendor": "VendorB"} + ] + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn + + response = client.get('/devices/network/topology', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert len(data["nodes"]) == 2 + assert len(data["links"]) == 1 + assert data["links"][0]["source"] == "AA:AA:AA:AA:AA:AA" + assert data["links"][0]["target"] == "BB:BB:BB:BB:BB:BB" + + +# --- get_recent_alerts Tests --- +@patch('models.event_instance.get_temp_db_connection') +def test_get_recent_alerts(mock_db_conn, client, api_token): + """Test get_recent_alerts.""" + # Mock database connection for events query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + mock_execute_result.fetchall.return_value = [ + {"eve_DateTime": now, "eve_EventType": "New Device", "eve_MAC": "AA:BB:CC:DD:EE:FF"} + ] + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn + + response = client.get('/events/recent', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert data["hours"] == 24 + + +# --- Device Alias Tests --- + +@patch('api_server.api_server_start.update_device_column') +def test_set_device_alias(mock_update_col, client, api_token): + """Test set_device_alias.""" + mock_update_col.return_value = {"success": True, "message": "Device alias updated"} + + payload = {"alias": "New Device Name"} + response = client.post('/device/AA:BB:CC:DD:EE:FF/set-alias', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + mock_update_col.assert_called_once_with("AA:BB:CC:DD:EE:FF", "devName", "New Device Name") + + +@patch('api_server.api_server_start.update_device_column') +def test_set_device_alias_not_found(mock_update_col, client, api_token): + """Test set_device_alias when device is not found.""" + mock_update_col.return_value = {"success": False, "error": "Device not found"} + + payload = {"alias": "New Device Name"} + response = client.post('/device/FF:FF:FF:FF:FF:FF/set-alias', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is False + assert "Device not found" in data["error"] + + +# --- Wake-on-LAN Tests --- + +@patch('api_server.api_server_start.wakeonlan') +def test_wol_wake_device(mock_wakeonlan, client, api_token): + """Test wol_wake_device.""" + mock_wakeonlan.return_value = {"success": True, "message": "WOL packet sent to AA:BB:CC:DD:EE:FF"} + + payload = {"devMac": "AA:BB:CC:DD:EE:FF"} + response = client.post('/nettools/wakeonlan', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "AA:BB:CC:DD:EE:FF" in data["message"] + + +def test_wol_wake_device_invalid_mac(client, api_token): + """Test wol_wake_device with invalid MAC.""" + payload = {"devMac": "invalid-mac"} + response = client.post('/nettools/wakeonlan', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + +# --- OpenAPI Spec Tests --- + +# --- Latest Device Tests --- + +@patch('models.device_instance.get_temp_db_connection') +def test_get_latest_device(mock_db_conn, client, api_token): + """Test get_latest_device endpoint.""" + # Mock database connection for latest device query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchone.return_value = { + "devName": "Latest Device", + "devMac": "AA:BB:CC:DD:EE:FF", + "devLastIP": "192.168.1.100", + "devFirstConnection": "2025-12-07 10:30:00" + } + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn + + response = client.get('/devices/latest', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 1 + assert data[0]["devName"] == "Latest Device" + assert data[0]["devMac"] == "AA:BB:CC:DD:EE:FF" + + +def test_openapi_spec(client, api_token): + """Test openapi_spec endpoint contains MCP tool paths.""" + response = client.get('/mcp/sse/openapi.json', headers=auth_headers(api_token)) + assert response.status_code == 200 + spec = response.get_json() + + # Check for MCP tool endpoints in the spec with correct paths + assert "/nettools/trigger-scan" in spec["paths"] + assert "/device/open_ports" in spec["paths"] + assert "/devices/network/topology" in spec["paths"] + assert "/events/recent" in spec["paths"] + assert "/device/{mac}/set-alias" in spec["paths"] + assert "/nettools/wakeonlan" in spec["paths"] diff --git a/test/test_graphq_endpoints.py b/test/test_graphq_endpoints.py index 38788f36..15078194 100755 --- a/test/test_graphq_endpoints.py +++ b/test/test_graphq_endpoints.py @@ -39,10 +39,10 @@ def test_graphql_debug_get(client): def test_graphql_post_unauthorized(client): - """POST /graphql without token should return 401""" + """POST /graphql without token should return 403""" query = {"query": "{ devices { devName devMac } }"} resp = client.post("/graphql", json=query) - assert resp.status_code == 401 + assert resp.status_code == 403 # Check either error field or message field for the unauthorized text error_text = resp.json.get("error", "") or resp.json.get("message", "") assert "Unauthorized" in error_text or "Forbidden" in error_text