Compare commits

...

14 Commits

Author SHA1 Message Date
Jokob @NetAlertX
1dee812ce6 cryptography build prevention + docs
Signed-off-by: GitHub <noreply@github.com>
2025-12-07 11:33:20 +00:00
Jokob @NetAlertX
5c44fd8fea cryptography build prevention
Signed-off-by: GitHub <noreply@github.com>
2025-12-07 11:09:18 +00:00
Jokob @NetAlertX
bd691f01b1 MCP refactor + cryptography build prevention
Signed-off-by: GitHub <noreply@github.com>
2025-12-07 10:51:18 +00:00
Jokob @NetAlertX
624fd87ee7 MCP refactor
Signed-off-by: GitHub <noreply@github.com>
2025-12-07 10:24:33 +00:00
Jokob @NetAlertX
5d1c63375b MCP refactor
Signed-off-by: GitHub <noreply@github.com>
2025-12-07 08:37:55 +00:00
Jokob @NetAlertX
8c982cd476 MCP refactor
Signed-off-by: GitHub <noreply@github.com>
2025-12-07 08:20:51 +00:00
Jokob @NetAlertX
36e5751221 Merge branch 'main' into fix-pr-1309 2025-12-01 09:34:59 +00:00
Jokob @NetAlertX
dfd836527e api endpoints updates 2025-12-01 08:52:50 +00:00
Jokob @NetAlertX
8d5a663817 DevInstance and PluginObjectInstance expansion 2025-12-01 08:27:14 +00:00
Adam Outler
e64c490c8a Help ARM runners on github with rust and cargo required by pip 2025-11-30 01:04:12 +00:00
Adam Outler
531b66effe Coderabit changes 2025-11-29 02:44:55 +00:00
Adam Outler
5e4ad10fe0 Tidy up 2025-11-28 21:13:20 +00:00
Adam Outler
541b932b6d Add MCP to existing OpenAPI 2025-11-28 14:12:06 -05:00
Adam Outler
2bf3ff9f00 Add MCP server 2025-11-28 17:03:18 +00:00
29 changed files with 1934 additions and 176 deletions

View File

@@ -39,10 +39,11 @@ Backend loop phases (see `server/__main__.py` and `server/plugin.py`): `once`, `
## API/Endpoints quick map ## API/Endpoints quick map
- Flask app: `server/api_server/api_server_start.py` exposes routes like `/device/<mac>`, `/devices`, `/devices/export/{csv,json}`, `/devices/import`, `/devices/totals`, `/devices/by-status`, plus `nettools`, `events`, `sessions`, `dbquery`, `metrics`, `sync`. - Flask app: `server/api_server/api_server_start.py` exposes routes like `/device/<mac>`, `/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 <API_TOKEN>` via `get_setting_value('API_TOKEN')`. - Authorization: all routes expect header `Authorization: Bearer <API_TOKEN>` via `get_setting_value('API_TOKEN')`.
- All responses need to return `"success":<False:True>` 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 ## Conventions & helpers to reuse
- Settings: add/modify via `ccd()` in `server/initialise.py` or perplugin manifest. Never hardcode ports or secrets; use `get_setting_value()`. - Settings: add/modify via `ccd()` in `server/initialise.py` or perplugin 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. - 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. - DB helpers: prefer `server/db/db_helper.py` functions (e.g., `get_table_json`, device condition helpers) over raw SQL in new paths.

View File

@@ -26,13 +26,23 @@ ENV PATH="/opt/venv/bin:$PATH"
# Install build dependencies # Install build dependencies
COPY requirements.txt /tmp/requirements.txt 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 && python -m venv /opt/venv
# Create virtual environment owned by root, but readable by everyone else. This makes it easy to copy # Upgrade pip/wheel/setuptools and install Python packages
# into hardened stage without worrying about permissions and keeps image size small. Keeping the commands RUN python -m pip install --upgrade pip setuptools wheel && \
# together makes for a slightly smaller image size. pip install --no-cache-dir -r /tmp/requirements.txt && \
RUN pip install --no-cache-dir -r /tmp/requirements.txt && \
chmod -R u-rwx,g-rwx /opt chmod -R u-rwx,g-rwx /opt
# second stage is the main runtime stage with just the minimum required to run the application # second stage is the main runtime stage with just the minimum required to run the application

View File

@@ -36,9 +36,15 @@ Authorization: Bearer <API_TOKEN>
If the token is missing or invalid, the server will return: If the token is missing or invalid, the server will return:
```json ```json
{ "error": "Forbidden" } {
"success": false,
"message": "ERROR: Not authorized",
"error": "Forbidden"
}
``` ```
HTTP Status: **403 Forbidden**
--- ---
## Base URL ## Base URL
@@ -54,6 +60,8 @@ http://<server>:<GRAPHQL_PORT>/
> [!TIP] > [!TIP]
> When retrieving devices or settings try using the GraphQL API endpoint first as it is read-optimized. > 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 * [Device API Endpoints](API_DEVICE.md) Manage individual devices
* [Devices Collection](API_DEVICES.md) Bulk operations on multiple devices * [Devices Collection](API_DEVICES.md) Bulk operations on multiple devices
* [Events](API_EVENTS.md) Device event logging and management * [Events](API_EVENTS.md) Device event logging and management
@@ -69,6 +77,18 @@ http://<server>:<GRAPHQL_PORT>/
* [Logs](API_LOGS.md) Purging of logs and adding to the event execution queue for user triggered events * [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 * [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. See [Testing](API_TESTS.md) for example requests and usage.
--- ---

View File

@@ -16,10 +16,14 @@ All `/dbquery/*` endpoints require an API token in the HTTP headers:
Authorization: Bearer <API_TOKEN> Authorization: Bearer <API_TOKEN>
``` ```
If the token is missing or invalid: If the token is missing or invalid (HTTP 403):
```json ```json
{ "error": "Forbidden" } {
\"success\": false,
\"message\": \"ERROR: Not authorized\",
\"error\": \"Forbidden\"
}
``` ```
--- ---

View File

@@ -41,6 +41,8 @@ Manage a **single device** by its MAC address. Operations include retrieval, upd
* Device not found → HTTP 404 * Device not found → HTTP 404
* Unauthorized → HTTP 403 * 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 ## 2. Update Device Fields

View File

@@ -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 ## Example `curl` Requests
**Get All Devices**: **Get All Devices**:
@@ -247,3 +334,26 @@ curl -X GET "http://<server_ip>:<GRAPHQL_PORT>/devices/by-status?status=online"
-H "Authorization: Bearer <API_TOKEN>" -H "Authorization: Bearer <API_TOKEN>"
``` ```
**Search Devices**:
```sh
curl -X POST "http://<server_ip>:<GRAPHQL_PORT>/devices/search" \
-H "Authorization: Bearer <API_TOKEN>" \
-H "Content-Type: application/json" \
--data '{"query": "192.168.1"}'
```
**Get Latest Device**:
```sh
curl -X GET "http://<server_ip>:<GRAPHQL_PORT>/devices/latest" \
-H "Authorization: Bearer <API_TOKEN>"
```
**Get Network Topology**:
```sh
curl -X GET "http://<server_ip>:<GRAPHQL_PORT>/devices/network/topology" \
-H "Authorization: Bearer <API_TOKEN>"
```

View File

@@ -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/<hours>` → 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=<period>` * **GET** `/sessions/totals?period=<period>`
Return event and session totals over a given 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 ## Notes
* All endpoints require **authorization** (Bearer token). Unauthorized requests return: * All endpoints require **authorization** (Bearer token). Unauthorized requests return HTTP 403:
```json ```json
{ "error": "Forbidden" } {
"success": false,
"message": "ERROR: Not authorized",
"error": "Forbidden"
}
``` ```
* Events are stored in the **Events table** with the following fields: * Events are stored in the **Events table** with the following fields:

326
docs/API_MCP.md Normal file
View File

@@ -0,0 +1,326 @@
# MCP Server Bridge API
The **MCP (Model Context Protocol) Server Bridge** provides AI assistants with standardized access to NetAlertX functionality through tools and server-sent events. This enables AI systems to interact with your network monitoring data in real-time.
---
## Overview
The MCP Server Bridge exposes NetAlertX functionality as **MCP Tools** that AI assistants can call to:
- Search and retrieve device information
- Trigger network scans
- Get network topology and events
- Wake devices via Wake-on-LAN
- Access open port information
- Set device aliases
All MCP endpoints mirror the functionality of standard REST endpoints but are optimized for AI assistant integration.
---
## Authentication
MCP endpoints use the same **Bearer token authentication** as REST endpoints:
```http
Authorization: Bearer <API_TOKEN>
```
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 <API_TOKEN>'
}
});
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/<mac>` | 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/<mac>/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:<GRAPHQL_PORT>",
"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 <API_TOKEN>",
"http://your-server:<GRAPHQL_PORT>/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

View File

@@ -241,3 +241,12 @@ curl -X POST "http://<server_ip>:<GRAPHQL_PORT>/nettools/nmap" \
curl "http://<server_ip>:<GRAPHQL_PORT>/nettools/internetinfo" \ curl "http://<server_ip>:<GRAPHQL_PORT>/nettools/internetinfo" \
-H "Authorization: Bearer <API_TOKEN>" -H "Authorization: Bearer <API_TOKEN>"
``` ```
---
## 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.

View File

@@ -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 logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
from const import logPath # 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 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] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression]
import conf # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression]
from pytz import timezone # 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"}, {"devMac": "00:11:22:33:44:57", "devLastIP": "192.168.1.82"},
] ]
else: else:
db = DB() device_handler = DeviceInstance()
db.open()
device_handler = DeviceInstance(db)
devices = ( devices = (
device_handler.getAll() device_handler.getAll()
if get_setting_value("REFRESH_FQDN") if get_setting_value("REFRESH_FQDN")

View File

@@ -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 logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
from const import logPath # 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 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] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression]
import conf # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression]
from pytz import timezone # 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') 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 # Initialize the Plugin obj output file
plugin_objects = Plugin_Objects(RESULT_FILE) plugin_objects = Plugin_Objects(RESULT_FILE)
# Create a DeviceInstance instance # Create a DeviceInstance instance
device_handler = DeviceInstance(db) device_handler = DeviceInstance()
# Retrieve devices # Retrieve devices
if get_setting_value("REFRESH_FQDN"): if get_setting_value("REFRESH_FQDN"):

View File

@@ -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 logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value # 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 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] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression]
import conf # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression]
from pytz import timezone # 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') args = get_setting_value('ICMP_ARGS')
in_regex = get_setting_value('ICMP_IN_REGEX') 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 # Initialize the Plugin obj output file
plugin_objects = Plugin_Objects(RESULT_FILE) plugin_objects = Plugin_Objects(RESULT_FILE)
# Create a DeviceInstance instance # Create a DeviceInstance instance
device_handler = DeviceInstance(db) device_handler = DeviceInstance()
# Retrieve devices # Retrieve devices
all_devices = device_handler.getAll() all_devices = device_handler.getAll()

View File

@@ -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 logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
from const import logPath # 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 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] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression]
import conf # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression]
from pytz import timezone # 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 = get_setting_value('NBLOOKUP_RUN_TIMEOUT')
timeout = 20 timeout = 20
# Create a database connection
db = DB() # instance of class DB
db.open()
# Initialize the Plugin obj output file # Initialize the Plugin obj output file
plugin_objects = Plugin_Objects(RESULT_FILE) plugin_objects = Plugin_Objects(RESULT_FILE)
# Create a DeviceInstance instance # Create a DeviceInstance instance
device_handler = DeviceInstance(db) device_handler = DeviceInstance()
# Retrieve devices # Retrieve devices
if get_setting_value("REFRESH_FQDN"): if get_setting_value("REFRESH_FQDN"):

View File

@@ -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 logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value # 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 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] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression]
import conf # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression]
from pytz import timezone # 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') 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 # Initialize the Plugin obj output file
plugin_objects = Plugin_Objects(RESULT_FILE) plugin_objects = Plugin_Objects(RESULT_FILE)
# Create a DeviceInstance instance # Create a DeviceInstance instance
device_handler = DeviceInstance(db) device_handler = DeviceInstance()
# Retrieve devices # Retrieve devices
if get_setting_value("REFRESH_FQDN"): if get_setting_value("REFRESH_FQDN"):

View File

@@ -256,13 +256,11 @@ def main():
start_time = time.time() start_time = time.time()
mylog("verbose", [f"[{pluginName}] starting execution"]) mylog("verbose", [f"[{pluginName}] starting execution"])
from database import DB
from models.device_instance import DeviceInstance from models.device_instance import DeviceInstance
db = DB() # instance of class DB
db.open()
# Create a DeviceInstance instance # Create a DeviceInstance instance
device_handler = DeviceInstance(db) device_handler = DeviceInstance()
# Retrieve configuration settings # Retrieve configuration settings
# these should be self-explanatory # these should be self-explanatory
omada_sites = [] omada_sites = []

View File

@@ -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 logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
from const import logPath # 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 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] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression]
import conf # 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}']) 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 # Create a DeviceInstance instance
device_handler = DeviceInstance(db) device_handler = DeviceInstance()
# Retrieve devices # Retrieve devices
if 'offline' in devices_to_wake: if 'offline' in devices_to_wake:

View File

@@ -98,6 +98,7 @@ nav:
- Sync: API_SYNC.md - Sync: API_SYNC.md
- GraphQL: API_GRAPHQL.md - GraphQL: API_GRAPHQL.md
- DB query: API_DBQUERY.md - DB query: API_DBQUERY.md
- MCP: API_MCP.md
- Tests: API_TESTS.md - Tests: API_TESTS.md
- SUPERSEDED OLD API Overview: API_OLD.md - SUPERSEDED OLD API Overview: API_OLD.md
- Integrations: - Integrations:

View File

@@ -1,3 +1,4 @@
cryptography<40
openwrt-luci-rpc openwrt-luci-rpc
asusrouter asusrouter
aiohttp aiohttp
@@ -30,3 +31,4 @@ urllib3
httplib2 httplib2
gunicorn gunicorn
git+https://github.com/foreign-sub/aiofreepybox.git git+https://github.com/foreign-sub/aiofreepybox.git
mcp

View File

@@ -3,11 +3,12 @@ import sys
import os import os
from flask import Flask, request, jsonify, Response from flask import Flask, request, jsonify, Response
from models.device_instance import DeviceInstance # noqa: E402
from flask_cors import CORS from flask_cors import CORS
# Register NetAlertX directories # Register NetAlertX directories
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") 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 logger import mylog # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value # 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 .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 .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.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] from messaging.in_app import ( # noqa: E402 [flake8 lint suppression]
write_notification, write_notification,
mark_all_notifications_read, mark_all_notifications_read,
@@ -71,9 +78,17 @@ from messaging.in_app import ( # noqa: E402 [flake8 lint suppression]
delete_notification, delete_notification,
mark_notification_as_read 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 # Flask application
app = Flask(__name__) app = Flask(__name__)
CORS( CORS(
app, app,
resources={ resources={
@@ -88,22 +103,61 @@ CORS(
r"/messaging/*": {"origins": "*"}, r"/messaging/*": {"origins": "*"},
r"/events/*": {"origins": "*"}, r"/events/*": {"origins": "*"},
r"/logs/*": {"origins": "*"}, r"/logs/*": {"origins": "*"},
r"/auth/*": {"origins": "*"} r"/api/tools/*": {"origins": "*"},
r"/auth/*": {"origins": "*"},
r"/mcp/*": {"origins": "*"}
}, },
supports_credentials=True, supports_credentials=True,
allow_headers=["Authorization", "Content-Type"], allow_headers=["Authorization", "Content-Type"],
) )
# -------------------------------------------------------------------------------
# MCP bridge variables + helpers (moved from mcp_routes)
# -------------------------------------------------------------------------------
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('/api/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 # 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 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) @app.errorhandler(404)
def not_found(error): 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 = { response = {
"success": False, "success": False,
"error": "API route not found", "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 return jsonify(response), 404
@@ -126,7 +180,7 @@ def graphql_endpoint():
if not is_authorized(): if not is_authorized():
msg = '[graphql_server] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct.' msg = '[graphql_server] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct.'
mylog('verbose', [msg]) 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 # Retrieve and log request data
data = request.get_json() data = request.get_json()
@@ -146,11 +200,12 @@ def graphql_endpoint():
return jsonify(response) return jsonify(response)
# Tools endpoints are registered via `mcp_endpoint.tools_bp` blueprint.
# -------------------------- # --------------------------
# Settings Endpoints # Settings Endpoints
# -------------------------- # --------------------------
@app.route("/settings/<setKey>", methods=["GET"]) @app.route("/settings/<setKey>", methods=["GET"])
def api_get_setting(setKey): def api_get_setting(setKey):
if not is_authorized(): if not is_authorized():
@@ -162,8 +217,7 @@ def api_get_setting(setKey):
# -------------------------- # --------------------------
# Device Endpoints # Device Endpoints
# -------------------------- # --------------------------
@app.route('/mcp/sse/device/<mac>', methods=['GET', 'POST'])
@app.route("/device/<mac>", methods=["GET"]) @app.route("/device/<mac>", methods=["GET"])
def api_get_device(mac): def api_get_device(mac):
if not is_authorized(): if not is_authorized():
@@ -229,11 +283,45 @@ def api_update_device_column(mac):
return update_device_column(mac, column_name, column_value) return update_device_column(mac, column_name, column_value)
@app.route('/mcp/sse/device/<mac>/set-alias', methods=['POST'])
@app.route('/device/<mac>/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 # Devices Collections
# -------------------------- # --------------------------
@app.route("/devices", methods=["GET"]) @app.route("/devices", methods=["GET"])
def api_get_devices(): def api_get_devices():
if not is_authorized(): if not is_authorized():
@@ -289,6 +377,7 @@ def api_devices_totals():
return devices_totals() return devices_totals()
@app.route('/mcp/sse/devices/by-status', methods=['GET', 'POST'])
@app.route("/devices/by-status", methods=["GET"]) @app.route("/devices/by-status", methods=["GET"])
def api_devices_by_status(): def api_devices_by_status():
if not is_authorized(): if not is_authorized():
@@ -299,15 +388,93 @@ def api_devices_by_status():
return devices_by_status(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({"error": "Missing 'query' parameter"}), 400
if is_mac(query):
device_data = get_device_data(query)
if device_data.status_code == 200:
return jsonify({"success": True, "devices": [device_data.get_json()]})
else:
return jsonify({"success": False, "error": "Device not found"}), 404
# Create fresh DB instance for this thread
device_handler = DeviceInstance()
matches = device_handler.search(query)
if not matches:
return jsonify({"success": False, "error": "No devices found"}), 404
return jsonify({"success": True, "devices": matches})
@app.route('/mcp/sse/devices/latest', methods=['GET'])
@app.route('/devices/latest', methods=['GET'])
def api_devices_latest():
"""Get latest device (most recent) - maps to DeviceInstance.getLatest()."""
if not is_authorized():
return jsonify({"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 # Net tools
# -------------------------- # --------------------------
@app.route('/mcp/sse/nettools/wakeonlan', methods=['POST'])
@app.route("/nettools/wakeonlan", methods=["POST"]) @app.route("/nettools/wakeonlan", methods=["POST"])
def api_wakeonlan(): def api_wakeonlan():
if not is_authorized(): if not is_authorized():
return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 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) return wakeonlan(mac)
@@ -368,11 +535,42 @@ def api_internet_info():
return 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 # DB query
# -------------------------- # --------------------------
@app.route("/dbquery/read", methods=["POST"]) @app.route("/dbquery/read", methods=["POST"])
def dbquery_read(): def dbquery_read():
if not is_authorized(): if not is_authorized():
@@ -395,6 +593,7 @@ def dbquery_write():
data = request.get_json() or {} data = request.get_json() or {}
raw_sql_b64 = data.get("rawSql") raw_sql_b64 = data.get("rawSql")
if not raw_sql_b64: if not raw_sql_b64:
return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "rawSql is required"}), 400 return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "rawSql is required"}), 400
return write_query(raw_sql_b64) return write_query(raw_sql_b64)
@@ -460,11 +659,13 @@ def api_delete_online_history():
@app.route("/logs", methods=["DELETE"]) @app.route("/logs", methods=["DELETE"])
def api_clean_log(): def api_clean_log():
if not is_authorized(): if not is_authorized():
return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403
file = request.args.get("file") file = request.args.get("file")
if not file: if not file:
return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing 'file' query parameter"}), 400 return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing 'file' query parameter"}), 400
return clean_log(file) return clean_log(file)
@@ -499,8 +700,6 @@ def api_add_to_execution_queue():
# -------------------------- # --------------------------
# Device Events # Device Events
# -------------------------- # --------------------------
@app.route("/events/create/<mac>", methods=["POST"]) @app.route("/events/create/<mac>", methods=["POST"])
def api_create_event(mac): def api_create_event(mac):
if not is_authorized(): if not is_authorized():
@@ -564,6 +763,45 @@ def api_get_events_totals():
return get_events_totals(period) 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/<int:hours>', methods=['GET'])
def api_events_recent(hours):
"""Return events from the last <hours> 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 # Sessions
# -------------------------- # --------------------------
@@ -793,3 +1031,9 @@ def start_server(graphql_port, app_state):
# Update the state to indicate the server has started # Update the state to indicate the server has started
app_state = updateState("Process: Idle", None, None, None, 1) 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

View File

@@ -228,7 +228,8 @@ def devices_totals():
def devices_by_status(status=None): 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() conn = get_temp_db_connection()

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python
import threading
from flask import Blueprint, request, jsonify, Response, stream_with_context
from helper import get_setting_value
from helper import mylog
# from .events_endpoint import get_events # will import locally where needed
import requests
import json
import uuid
import queue
# Blueprints
mcp_bp = Blueprint('mcp', __name__)
tools_bp = Blueprint('tools', __name__)
mcp_sessions = {}
mcp_sessions_lock = threading.Lock()
def check_auth():
token = request.headers.get("Authorization")
expected_token = f"Bearer {get_setting_value('API_TOKEN')}"
return token == expected_token
# --------------------------
# Specs
# --------------------------
def openapi_spec():
# Spec matching actual available routes for MCP tools
mylog("verbose", ["[MCP] OpenAPI spec requested"])
spec = {
"openapi": "3.0.0",
"info": {"title": "NetAlertX Tools", "version": "1.1.0"},
"servers": [{"url": "/"}],
"paths": {
"/devices/by-status": {"post": {"operationId": "list_devices"}},
"/device/{mac}": {"post": {"operationId": "get_device_info"}},
"/devices/search": {"post": {"operationId": "search_devices"}},
"/devices/latest": {"get": {"operationId": "get_latest_device"}},
"/nettools/trigger-scan": {"post": {"operationId": "trigger_scan"}},
"/device/open_ports": {"post": {"operationId": "get_open_ports"}},
"/devices/network/topology": {"get": {"operationId": "get_network_topology"}},
"/events/recent": {"get": {"operationId": "get_recent_alerts"}, "post": {"operationId": "get_recent_alerts"}},
"/events/last": {"get": {"operationId": "get_last_events"}, "post": {"operationId": "get_last_events"}},
"/device/{mac}/set-alias": {"post": {"operationId": "set_device_alias"}},
"/nettools/wakeonlan": {"post": {"operationId": "wol_wake_device"}}
}
}
return jsonify(spec)
# --------------------------
# MCP SSE/JSON-RPC Endpoint
# --------------------------
# Sessions for SSE
_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:
r = requests.get(f"{API_BASE_URL}/mcp/openapi.json", timeout=10)
r.raise_for_status()
_openapi_spec_cache = r.json()
return _openapi_spec_cache
except Exception 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')

View File

@@ -0,0 +1,304 @@
"""MCP bridge routes exposing NetAlertX tool endpoints via JSON-RPC."""
import json
import uuid
import queue
import requests
import threading
import logging
from flask import Blueprint, request, Response, stream_with_context, jsonify
from helper import get_setting_value
mcp_bp = Blueprint('mcp', __name__)
# Store active sessions: session_id -> Queue
sessions = {}
sessions_lock = threading.Lock()
# Cache for OpenAPI spec to avoid fetching on every request
openapi_spec_cache = None
BACKEND_PORT = get_setting_value("GRAPHQL_PORT")
API_BASE_URL = f"http://localhost:{BACKEND_PORT}/api/tools"
def get_openapi_spec():
"""Fetch and cache the tools OpenAPI specification from the local API server."""
global openapi_spec_cache
if openapi_spec_cache:
return openapi_spec_cache
try:
# Fetch from local server
# We use localhost because this code runs on the server
response = requests.get(f"{API_BASE_URL}/openapi.json", timeout=10)
response.raise_for_status()
openapi_spec_cache = response.json()
return openapi_spec_cache
except Exception as e:
print(f"Error fetching OpenAPI spec: {e}")
return None
def map_openapi_to_mcp_tools(spec):
"""Convert OpenAPI paths into MCP tool descriptors."""
tools = []
if not spec or "paths" not in spec:
return tools
for path, methods in spec["paths"].items():
for method, details in methods.items():
if "operationId" in details:
tool = {
"name": details["operationId"],
"description": details.get("description", details.get("summary", "")),
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
}
# Extract parameters from requestBody if present
if "requestBody" in details:
content = details["requestBody"].get("content", {})
if "application/json" in content:
schema = content["application/json"].get("schema", {})
tool["inputSchema"] = schema.copy()
if "properties" not in tool["inputSchema"]:
tool["inputSchema"]["properties"] = {}
if "required" not in tool["inputSchema"]:
tool["inputSchema"]["required"] = []
# Extract parameters from 'parameters' list (query/path params) - simplistic support
if "parameters" in details:
for param in details["parameters"]:
if param.get("in") == "query":
tool["inputSchema"]["properties"][param["name"]] = {
"type": param.get("schema", {}).get("type", "string"),
"description": param.get("description", "")
}
if param.get("required"):
if "required" not in tool["inputSchema"]:
tool["inputSchema"]["required"] = []
tool["inputSchema"]["required"].append(param["name"])
tools.append(tool)
return tools
def process_mcp_request(data):
"""Handle incoming MCP JSON-RPC requests and route them to tools."""
method = data.get("method")
msg_id = data.get("id")
response = None
if method == "initialize":
response = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "NetAlertX",
"version": "1.0.0"
}
}
}
elif method == "notifications/initialized":
# No response needed for notification
pass
elif method == "tools/list":
spec = get_openapi_spec()
tools = map_openapi_to_mcp_tools(spec)
response = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"tools": tools
}
}
elif method == "tools/call":
params = data.get("params", {})
tool_name = params.get("name")
tool_args = params.get("arguments", {})
# Find the endpoint for this tool
spec = get_openapi_spec()
target_path = None
target_method = None
if spec and "paths" in spec:
for path, methods in spec["paths"].items():
for m, details in methods.items():
if details.get("operationId") == tool_name:
target_path = path
target_method = m.upper()
break
if target_path:
break
if target_path:
try:
# Make the request to the local API
# We forward the Authorization header from the incoming request if present
headers = {
"Content-Type": "application/json"
}
if "Authorization" in request.headers:
headers["Authorization"] = request.headers["Authorization"]
url = f"{API_BASE_URL}{target_path}"
if target_method == "POST":
api_res = requests.post(url, json=tool_args, headers=headers, timeout=30)
elif target_method == "GET":
api_res = requests.get(url, params=tool_args, headers=headers, timeout=30)
else:
api_res = None
if api_res:
content = []
try:
json_content = api_res.json()
content.append({
"type": "text",
"text": json.dumps(json_content, indent=2)
})
except (ValueError, json.JSONDecodeError):
content.append({
"type": "text",
"text": api_res.text
})
is_error = api_res.status_code >= 400
response = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": content,
"isError": is_error
}
}
else:
response = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method {target_method} not supported"}
}
except Exception as e:
response = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [{"type": "text", "text": f"Error calling tool: {str(e)}"}],
"isError": True
}
}
else:
response = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Tool {tool_name} not found"}
}
elif method == "ping":
response = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {}
}
else:
# Unknown method
if msg_id: # Only respond if it's a request (has id)
response = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": "Method not found"}
}
return response
@mcp_bp.route('/sse', methods=['GET', 'POST'])
def handle_sse():
"""Expose an SSE endpoint that streams MCP responses to connected clients."""
if request.method == 'POST':
# Handle verification or keep-alive pings
try:
data = request.get_json(silent=True)
if data and "method" in data and "jsonrpc" in data:
response = process_mcp_request(data)
if response:
return jsonify(response)
else:
# Notification or no response needed
return "", 202
except Exception as e:
# Log but don't fail - malformed requests shouldn't crash the endpoint
logging.getLogger(__name__).debug(f"SSE POST processing error: {e}")
return jsonify({"status": "ok", "message": "MCP SSE endpoint active"}), 200
session_id = uuid.uuid4().hex
q = queue.Queue()
with sessions_lock:
sessions[session_id] = q
def stream():
"""Yield SSE messages for queued MCP responses until the client disconnects."""
# Send the endpoint event
# The client should POST to /api/mcp/messages?session_id=<session_id>
yield f"event: endpoint\ndata: /api/mcp/messages?session_id={session_id}\n\n"
try:
while True:
try:
# Wait for messages
message = q.get(timeout=20) # Keep-alive timeout
yield f"event: message\ndata: {json.dumps(message)}\n\n"
except queue.Empty:
# Send keep-alive comment
yield ": keep-alive\n\n"
except GeneratorExit:
with sessions_lock:
if session_id in sessions:
del sessions[session_id]
return Response(stream_with_context(stream()), mimetype='text/event-stream')
@mcp_bp.route('/messages', methods=['POST'])
def handle_messages():
"""Receive MCP JSON-RPC messages and enqueue responses for an SSE session."""
session_id = request.args.get('session_id')
if not session_id:
return jsonify({"error": "Missing session_id"}), 400
with sessions_lock:
if session_id not in sessions:
return jsonify({"error": "Session not found"}), 404
q = sessions[session_id]
data = request.json
if not data:
return jsonify({"error": "Invalid JSON"}), 400
response = process_mcp_request(data)
if response:
q.put(response)
return jsonify({"status": "accepted"}), 202

View File

@@ -1,83 +1,134 @@
from front.plugins.plugin_helper import is_mac
from logger import mylog from logger import mylog
from models.plugin_object_instance import PluginObjectInstance
from database import get_temp_db_connection
# -------------------------------------------------------------------------------
# Device object handling (WIP)
# -------------------------------------------------------------------------------
class DeviceInstance: 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): def getAll(self):
self.db.sql.execute(""" return self._fetchall("SELECT * FROM Devices")
SELECT * FROM Devices
""")
return self.db.sql.fetchall()
# Get all with unknown names
def getUnknown(self): def getUnknown(self):
self.db.sql.execute(""" return self._fetchall("""
SELECT * FROM Devices WHERE devName in ("(unknown)", "(name not found)", "" ) 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): def getValueWithMac(self, column_name, devMac):
query = f"SELECT {column_name} FROM Devices WHERE devMac = ?" row = self._fetchone(f"""
self.db.sql.execute(query, (devMac,)) SELECT {column_name} FROM Devices WHERE devMac = ?
result = self.db.sql.fetchone() """, (devMac,))
return result[column_name] if result else None return row.get(column_name) if row else None
# Get all down
def getDown(self): def getDown(self):
self.db.sql.execute(""" return self._fetchall("""
SELECT * FROM Devices WHERE devAlertDown = 1 and devPresentLastScan = 0 SELECT * FROM Devices
WHERE devAlertDown = 1 AND devPresentLastScan = 0
""") """)
return self.db.sql.fetchall()
# Get all down
def getOffline(self): def getOffline(self):
self.db.sql.execute(""" return self._fetchall("""
SELECT * FROM Devices WHERE devPresentLastScan = 0 SELECT * FROM Devices
WHERE devPresentLastScan = 0
""") """)
return self.db.sql.fetchall()
# Get a device by devGUID
def getByGUID(self, devGUID): def getByGUID(self, devGUID):
self.db.sql.execute("SELECT * FROM Devices WHERE devGUID = ?", (devGUID,)) return self._fetchone("""
result = self.db.sql.fetchone() SELECT * FROM Devices WHERE devGUID = ?
return dict(result) if result else None """, (devGUID,))
# Check if a device exists by devGUID
def exists(self, devGUID): def exists(self, devGUID):
self.db.sql.execute( row = self._fetchone("""
"SELECT COUNT(*) AS count FROM Devices WHERE devGUID = ?", (devGUID,) SELECT COUNT(*) as count FROM Devices WHERE devGUID = ?
) """, (devGUID,))
result = self.db.sql.fetchone() return row['count'] > 0 if row else False
return result["count"] > 0
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): def updateField(self, devGUID, field, value):
if not self.exists(devGUID): if not self.exists(devGUID):
m = f"[Device] In 'updateField': GUID {devGUID} not found." msg = f"[Device] updateField: GUID {devGUID} not found"
mylog("none", m) mylog("none", msg)
raise ValueError(m) 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): def delete(self, devGUID):
if not self.exists(devGUID): if not self.exists(devGUID):
m = f"[Device] In 'delete': GUID {devGUID} not found." msg = f"[Device] delete: GUID {devGUID} not found"
mylog("none", m) mylog("none", msg)
raise ValueError(m) raise ValueError(msg)
self._execute("DELETE FROM Devices WHERE devGUID=?", (devGUID,))
self.db.sql.execute("DELETE FROM Devices WHERE devGUID = ?", (devGUID,)) def resolvePrimaryID(self, target):
self.db.commitDB() 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

View File

@@ -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

View File

@@ -1,70 +1,91 @@
from logger import mylog from logger import mylog
from database import get_temp_db_connection
# ------------------------------------------------------------------------------- # -------------------------------------------------------------------------------
# Plugin object handling (WIP) # Plugin object handling (THREAD-SAFE REWRITE)
# ------------------------------------------------------------------------------- # -------------------------------------------------------------------------------
class PluginObjectInstance: 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): def getAll(self):
self.db.sql.execute(""" return self._fetchall("SELECT * FROM Plugins_Objects")
SELECT * FROM Plugins_Objects
""")
return self.db.sql.fetchall()
# Get plugin object by ObjectGUID
def getByGUID(self, ObjectGUID): def getByGUID(self, ObjectGUID):
self.db.sql.execute( return self._fetchone(
"SELECT * FROM Plugins_Objects WHERE ObjectGUID = ?", (ObjectGUID,) "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): def exists(self, ObjectGUID):
self.db.sql.execute( row = self._fetchone("""
"SELECT COUNT(*) AS count FROM Plugins_Objects WHERE ObjectGUID = ?", SELECT COUNT(*) AS count FROM Plugins_Objects WHERE ObjectGUID = ?
(ObjectGUID,), """, (ObjectGUID,))
) return row["count"] > 0 if row else False
result = self.db.sql.fetchone()
return result["count"] > 0
# Get objects by plugin name
def getByPlugin(self, plugin): def getByPlugin(self, plugin):
self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,)) return self._fetchall(
return self.db.sql.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): def getByStatus(self, status):
self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Status = ?", (status,)) return self._fetchall("""
return self.db.sql.fetchall() SELECT * FROM Plugins_Objects WHERE Status = ?
""", (status,))
# Update a specific field for a plugin object
def updateField(self, ObjectGUID, field, value): def updateField(self, ObjectGUID, field, value):
if not self.exists(ObjectGUID): if not self.exists(ObjectGUID):
m = f"[PluginObject] In 'updateField': GUID {ObjectGUID} not found." msg = f"[PluginObject] updateField: GUID {ObjectGUID} not found."
mylog("none", m) mylog("none", msg)
raise ValueError(m) raise ValueError(msg)
self.db.sql.execute( self._execute(
f""" f"UPDATE Plugins_Objects SET {field}=? WHERE ObjectGUID=?",
UPDATE Plugins_Objects SET {field} = ? WHERE ObjectGUID = ? (value, ObjectGUID)
""",
(value, ObjectGUID),
) )
self.db.commitDB()
# Delete a plugin object by ObjectGUID
def delete(self, ObjectGUID): def delete(self, ObjectGUID):
if not self.exists(ObjectGUID): if not self.exists(ObjectGUID):
m = f"[PluginObject] In 'delete': GUID {ObjectGUID} not found." msg = f"[PluginObject] delete: GUID {ObjectGUID} not found."
mylog("none", m) mylog("none", msg)
raise ValueError(m) raise ValueError(msg)
self.db.sql.execute( self._execute("DELETE FROM Plugins_Objects WHERE ObjectGUID=?", (ObjectGUID,))
"DELETE FROM Plugins_Objects WHERE ObjectGUID = ?", (ObjectGUID,)
)
self.db.commitDB()

View File

@@ -650,7 +650,7 @@ def update_devices_names(pm):
sql = pm.db.sql sql = pm.db.sql
resolver = NameResolver(pm.db) resolver = NameResolver(pm.db)
device_handler = DeviceInstance(pm.db) device_handler = DeviceInstance()
nameNotFound = "(name not found)" nameNotFound = "(name not found)"

View File

@@ -42,13 +42,13 @@ class UpdateFieldAction(Action):
# currently unused # currently unused
if isinstance(obj, dict) and "ObjectGUID" in obj: if isinstance(obj, dict) and "ObjectGUID" in obj:
mylog("debug", f"[WF] Updating Object '{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) plugin_instance.updateField(obj["ObjectGUID"], self.field, self.value)
processed = True processed = True
elif isinstance(obj, dict) and "devGUID" in obj: elif isinstance(obj, dict) and "devGUID" in obj:
mylog("debug", f"[WF] Updating Device '{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) device_instance.updateField(obj["devGUID"], self.field, self.value)
processed = True processed = True
@@ -79,13 +79,13 @@ class DeleteObjectAction(Action):
# currently unused # currently unused
if isinstance(obj, dict) and "ObjectGUID" in obj: if isinstance(obj, dict) and "ObjectGUID" in obj:
mylog("debug", f"[WF] Updating Object '{obj}' ") mylog("debug", f"[WF] Updating Object '{obj}' ")
plugin_instance = PluginObjectInstance(self.db) plugin_instance = PluginObjectInstance()
plugin_instance.delete(obj["ObjectGUID"]) plugin_instance.delete(obj["ObjectGUID"])
processed = True processed = True
elif isinstance(obj, dict) and "devGUID" in obj: elif isinstance(obj, dict) and "devGUID" in obj:
mylog("debug", f"[WF] Updating Device '{obj}' ") mylog("debug", f"[WF] Updating Device '{obj}' ")
device_instance = DeviceInstance(self.db) device_instance = DeviceInstance()
device_instance.delete(obj["devGUID"]) device_instance.delete(obj["devGUID"])
processed = True processed = True

View File

@@ -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"]