Compare commits

...

39 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
mid
5af760f5ee Translated using Weblate (Japanese)
Some checks failed
Code checks / check-url-paths (push) Has been cancelled
Code checks / lint (push) Has been cancelled
Code checks / docker-tests (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
Currently translated at 100.0% (763 of 763 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/
2025-12-01 10:00:26 +01: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
jokob-sk
fbb4a2f8b4 BE: added /auth endpoint
Some checks failed
Code checks / check-url-paths (push) Has been cancelled
Code checks / lint (push) Has been cancelled
Code checks / docker-tests (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-12-01 09:24:44 +11:00
jokob-sk
54bce6505b PLG: SNMPDSC Fortinet support #1324
Some checks failed
Code checks / check-url-paths (push) Has been cancelled
Code checks / lint (push) Has been cancelled
Code checks / docker-tests (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-12-01 09:11:23 +11:00
jokob-sk
6da47cc830 DOCS: migration docs
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-12-01 08:32:22 +11:00
jokob-sk
9cabbf3622 Merge branch 'main' of https://github.com/jokob-sk/NetAlertX 2025-12-01 08:03:28 +11:00
jokob-sk
6c28a08bee FE: YYYY-DD-MM timestamp handling #1312
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-12-01 08:03:13 +11:00
Sylvain Pichon
86e3decd4e Translated using Weblate (French)
Some checks failed
Code checks / check-url-paths (push) Has been cancelled
Code checks / lint (push) Has been cancelled
Code checks / docker-tests (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
Currently translated at 100.0% (763 of 763 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/fr/
2025-11-30 08:01:30 +00:00
Safeguard
e14e0bb9e8 Translated using Weblate (Russian)
Currently translated at 100.0% (763 of 763 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/ru/
2025-11-30 08:01:28 +00:00
mid
b6023d1373 Translated using Weblate (Japanese)
Currently translated at 88.8% (678 of 763 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/
2025-11-30 08:01:24 +00:00
Максим Горпиніч
1812cc8ef8 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (763 of 763 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/uk/
2025-11-30 08:00:21 +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
jokob-sk
5df39f984a BE: docker version github action work #1320
Some checks failed
Code checks / check-url-paths (push) Has been cancelled
Code checks / lint (push) Has been cancelled
Code checks / docker-tests (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 12:00:18 +11:00
jokob-sk
d007ed711a BE: docker version github action work #1320
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 11:58:11 +11:00
jokob-sk
61824abb9f BE: restore previous version retrieval as a test #1320
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 11:21:24 +11:00
jokob-sk
33c5548fe1 Merge branch 'main' of https://github.com/jokob-sk/NetAlertX 2025-11-30 11:15:25 +11:00
jokob-sk
fd41c395ae DOCS: old link removal
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 11:15:19 +11:00
jokob-sk
1a980844f0 BE: restore previous verison retrieval as a test #1320
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 11:14:45 +11:00
jokob-sk
82e018e284 FE: more defensive network topology hierarchy check #1308
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 10:55:08 +11:00
jokob-sk
e0e1233b1c DOCS: migration docs
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 10:27:33 +11:00
jokob-sk
74677f940e FE: more defensive network topology hierarchy check #1308
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 10:27:23 +11:00
Jokob @NetAlertX
21a4d20579 Merge pull request #1317 from mmomjian/main
Fix typo in warning message for read-only mode
2025-11-29 23:17:43 +00:00
jokob-sk
9634e4e0f7 FE: YYYY-DD-MM timestamp handling #1312
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 09:36:56 +11:00
jokob-sk
00a47ab5d3 FE: config backups saved in incorrect location #1311
Some checks failed
Code checks / check-url-paths (push) Has been cancelled
Code checks / lint (push) Has been cancelled
Code checks / docker-tests (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-30 07:42:11 +11:00
Matthew Momjian
59b417705e Fix typo in warning message for read-only mode 2025-11-29 11:02:42 -05:00
jokob-sk
525d082f3d DOCS: volume
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-29 16:53:15 +11:00
jokob-sk
ba3481759b DOCS: Migration callouts
Some checks failed
Code checks / check-url-paths (push) Has been cancelled
Code checks / lint (push) Has been cancelled
Code checks / docker-tests (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2025-11-29 16:50:06 +11: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
51 changed files with 3245 additions and 1307 deletions

View File

@@ -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/<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')`.
- 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
- 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.
- 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

@@ -47,6 +47,12 @@ jobs:
id: get_version
run: echo "version=Dev" >> $GITHUB_OUTPUT
# --- debug output
- name: Debug version
run: |
echo "GITHUB_REF: $GITHUB_REF"
echo "Version: '${{ steps.get_version.outputs.version }}'"
# --- Write the timestamped version to .VERSION file
- name: Create .VERSION file
run: echo "${{ steps.timestamp.outputs.version }}" > .VERSION

View File

@@ -32,14 +32,34 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# --- Previous approach Get release version from tag
- name: Set up dynamic build ARGs
id: getargs
run: echo "version=$(cat ./stable/VERSION)" >> $GITHUB_OUTPUT
- name: Get release version
id: get_version_prev
run: echo "::set-output name=version::${GITHUB_REF#refs/tags/}"
- name: Create .VERSION file
run: echo "${{ steps.get_version.outputs.version }}" >> .VERSION_PREV
# --- Get release version from tag
- name: Get release version
id: get_version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
# --- debug output
- name: Debug version
run: |
echo "GITHUB_REF: $GITHUB_REF"
echo "Version: '${{ steps.get_version.outputs.version }}'"
echo "Version prev: '${{ steps.get_version_prev.outputs.version }}'"
# --- Write version to .VERSION file
- name: Create .VERSION file
run: echo "${{ steps.get_version.outputs.version }}" > .VERSION
run: echo -n "${{ steps.get_version.outputs.version }}" > .VERSION
# --- Generate Docker metadata and tags
- name: Docker meta

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ nohup.out
config/*
.ash_history
.VERSION
.VERSION_PREV
config/pialert.conf
config/app.conf
db/*

View File

@@ -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 --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
@@ -138,6 +148,7 @@ RUN install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 ${READ_WRITE_FO
# Copy version information into the image
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} .[V]ERSION ${NETALERTX_APP}/.VERSION
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} .[V]ERSION ${NETALERTX_APP}/.VERSION_PREV
# Copy the virtualenv from the builder stage
COPY --from=builder --chown=20212:20212 ${VIRTUAL_ENV} ${VIRTUAL_ENV}
@@ -147,12 +158,12 @@ COPY --from=builder --chown=20212:20212 ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# This is done after the copy of the venv to ensure the venv is in place
# although it may be quicker to do it before the copy, it keeps the image
# layers smaller to do it after.
RUN if [ -f '.VERSION' ]; then \
cp '.VERSION' "${NETALERTX_APP}/.VERSION"; \
else \
echo "DEVELOPMENT 00000000" > "${NETALERTX_APP}/.VERSION"; \
fi && \
chown 20212:20212 "${NETALERTX_APP}/.VERSION" && \
RUN for vfile in .VERSION .VERSION_PREV; do \
if [ ! -f "${NETALERTX_APP}/${vfile}" ]; then \
echo "DEVELOPMENT 00000000" > "${NETALERTX_APP}/${vfile}"; \
fi; \
chown 20212:20212 "${NETALERTX_APP}/${vfile}"; \
done && \
apk add --no-cache libcap && \
setcap cap_net_raw+ep /bin/busybox && \
setcap cap_net_raw,cap_net_admin+eip /usr/bin/nmap && \

View File

@@ -34,9 +34,7 @@ Get visibility of what's going on on your WIFI/LAN network and enable presence d
## 🚀 Quick Start
> [!WARNING]
> ⚠️ **Important:** The documentation has been recently updated and some instructions may have changed.
> If you are using the currently live production image, please follow the instructions on [Docker Hub](https://hub.docker.com/r/jokobsk/netalertx) for building and running the container.
> These docs reflect the latest development version and may differ from the production image.
> ⚠️ **Important:** The docker-compose has recently changed. Carefully read the [Migration guide](https://jokob-sk.github.io/NetAlertX/MIGRATION/?h=migrat#12-migration-from-netalertx-v25524) for detailed instructions.
Start NetAlertX in seconds with Docker:

View File

@@ -36,9 +36,15 @@ Authorization: Bearer <API_TOKEN>
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://<server>:<GRAPHQL_PORT>/
> [!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://<server>:<GRAPHQL_PORT>/
* [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.
---

View File

@@ -16,10 +16,14 @@ All `/dbquery/*` endpoints require an API token in the HTTP headers:
Authorization: Bearer <API_TOKEN>
```
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\"
}
```
---

View File

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

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
**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>"
```
**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>`
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:

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" \
-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

@@ -1,9 +1,7 @@
# NetAlertX and Docker Compose
> [!WARNING]
> ⚠️ **Important:** The documentation has been recently updated and some instructions may have changed.
> If you are using the currently live production image, please follow the instructions on [Docker Hub](https://hub.docker.com/r/jokobsk/netalertx) for building and running the container.
> These docs reflect the latest development version and may differ from the production image.
> ⚠️ **Important:** The docker-compose has recently changed. Carefully read the [Migration guide](https://jokob-sk.github.io/NetAlertX/MIGRATION/?h=migrat#12-migration-from-netalertx-v25524) for detailed instructions.
Great care is taken to ensure NetAlertX meets the needs of everyone while being flexible enough for anyone. This document outlines how you can configure your docker-compose. There are many settings, so we recommend using the Baseline Docker Compose as-is, or modifying it for your system.Good care is taken to ensure NetAlertX meets the needs of everyone while being flexible enough for anyone. This document outlines how you can configure your docker-compose. There are many settings, so we recommend using the Baseline Docker Compose as-is, or modifying it for your system.

View File

@@ -61,8 +61,7 @@ See alternative [docked-compose examples](https://github.com/jokob-sk/NetAlertX/
| Required | Path | Description |
| :------------- | :------------- | :-------------|
| ✅ | `:/data/config` | Folder which will contain the `app.conf` & `devices.csv` ([read about devices.csv](https://github.com/jokob-sk/NetAlertX/blob/main/docs/DEVICES_BULK_EDITING.md)) files |
| ✅ | `:/data/db` | Folder which will contain the `app.db` database file |
| ✅ | `:/data` | Folder which will contain the `/db/app.db`, `/config/app.conf` & `/config/devices.csv` ([read about devices.csv](https://github.com/jokob-sk/NetAlertX/blob/main/docs/DEVICES_BULK_EDITING.md)) files |
| ✅ | `/etc/localtime:/etc/localtime:ro` | Ensuring the timezone is teh same as on teh server. |
| | `:/tmp/log` | Logs folder useful for debugging if you have issues setting up the container |
| | `:/tmp/api` | The [API endpoint](https://github.com/jokob-sk/NetAlertX/blob/main/docs/API.md) containing static (but regularly updated) json and other files. Path configurable via `NETALERTX_API` environment variable. |

View File

@@ -1,9 +1,7 @@
# The NetAlertX Container Operator's Guide
> [!WARNING]
> ⚠️ **Important:** The documentation has been recently updated and some instructions may have changed.
> If you are using the currently live production image, please follow the instructions on [Docker Hub](https://hub.docker.com/r/jokobsk/netalertx) for building and running the container.
> These docs reflect the latest development version and may differ from the production image.
> ⚠️ **Important:** The docker-compose has recently changed. Carefully read the [Migration guide](https://jokob-sk.github.io/NetAlertX/MIGRATION/?h=migrat#12-migration-from-netalertx-v25524) for detailed instructions.
This guide assumes you are starting with the official `docker-compose.yml` file provided with the project. We strongly recommend you start with or migrate to this file as your baseline and modify it to suit your specific needs (e.g., changing file paths). While there are many ways to configure NetAlertX, the default file is designed to meet the mandatory security baseline with layer-2 networking capabilities while operating securely and without startup warnings.

View File

@@ -62,7 +62,7 @@ docker run -it --rm --name netalertx --user "0" \
>
> `sudo chown -R 20211:20211 /local_data_dir`
>
> `sudo chmod -R a+rwx /local_data_dir1`
> `sudo chmod -R a+rwx /local_data_dir`
>
---

View File

@@ -1,11 +1,5 @@
# Migration
> [!WARNING]
> ⚠️ **Important:** The documentation has been recently updated and some instructions may have changed.
> If you are using the currently live production image, please follow the instructions on [Docker Hub](https://hub.docker.com/r/jokobsk/netalertx) for building and running the container.
> These docs reflect the latest development version and may differ from the production image.
When upgrading from older versions of NetAlertX (or PiAlert by jokob-sk), follow the migration steps below to ensure your data and configuration are properly transferred.
> [!TIP]
@@ -245,30 +239,7 @@ services:
4. Start the container and verify everything works as expected.
5. Stop the container.
6. Perform a one-off migration to the latest `netalertx` image and `20211` user:
> [!NOTE]
> The example below assumes your `/config` and `/db` folders are stored in `local_data_dir`.
> Replace this path with your actual configuration directory. `netalertx` is the container name, which might differ from your setup.
```sh
docker run -it --rm --name netalertx --user "0" \
-v /local_data_dir/config:/data/config \
-v /local_data_dir/db:/data/db \
--tmpfs /tmp:uid=20211,gid=20211,mode=1700 \
ghcr.io/jokob-sk/netalertx:latest
```
..or alternatively execute:
```bash
sudo chown -R 20211:20211 /local_data_dir/config
sudo chown -R 20211:20211 /local_data_dir/db
sudo chmod -R a+rwx /local_data_dir/
```
7. Stop the container
8. Update the `docker-compose.yml` as per example below.
6. Update the `docker-compose.yml` as per example below.
```yaml
services:
@@ -295,5 +266,33 @@ services:
- "/tmp:uid=20211,gid=20211,mode=1700,rw,noexec,nosuid,nodev,async,noatime,nodiratime"
# 🆕 New "tmpfs" section END 🔼
```
7. Perform a one-off migration to the latest `netalertx` image and `20211` user.
9. Start the container and verify everything works as expected.
> [!NOTE]
> The examples below assumes your `/config` and `/db` folders are stored in `local_data_dir`.
> Replace this path with your actual configuration directory. `netalertx` is the container name, which might differ from your setup.
**Automated approach**:
Run the container with the `--user "0"` parameter. Please note, some systems will require the manual approach below.
```sh
docker run -it --rm --name netalertx --user "0" \
-v /local_data_dir/config:/data/config \
-v /local_data_dir/db:/data/db \
--tmpfs /tmp:uid=20211,gid=20211,mode=1700 \
ghcr.io/jokob-sk/netalertx:latest
```
Stop the container and run it as you would normally.
**Manual approach**:
Use the manual approach if the Automated approach fails. Execute the below commands:
```bash
sudo chown -R 20211:20211 /local_data_dir
sudo chmod -R a+rwx /local_data_dir
```
8. Start the container and verify everything works as expected.

View File

@@ -63,7 +63,6 @@ There is also an in-app Help / FAQ section that should be answering frequently a
#### ♻ Misc
- [Version history (legacy)](./VERSIONS_HISTORY.md)
- [Reverse proxy (Nginx, Apache, SWAG)](./REVERSE_PROXY.md)
- [Installing Updates](./UPDATES.md)
- [Setting up Authelia](./AUTHELIA.md) (DRAFT)

View File

@@ -84,5 +84,5 @@ services:
>
> `sudo chown -R 20211:20211 /local_data_dir`
>
> `sudo chmod -R a+rwx /local_data_dir1`
> `sudo chmod -R a+rwx /local_data_dir`
>

View File

@@ -378,7 +378,7 @@ function localizeTimestamp(input) {
let tz = getSetting("TIMEZONE") || 'Europe/Berlin';
input = String(input || '').trim();
// 1. Unix timestamps (10 or 13 digits)
// 1. Unix timestamps (10 or 13 digits)
if (/^\d+$/.test(input)) {
const ms = input.length === 10 ? parseInt(input, 10) * 1000 : parseInt(input, 10);
return new Intl.DateTimeFormat('default', {
@@ -389,39 +389,59 @@ function localizeTimestamp(input) {
}).format(new Date(ms));
}
// 2. European DD/MM/YYYY
let match = input.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:[ ,]+(\d{1,2}:\d{2}(?::\d{2})?))?(.*)$/);
// 2. European DD/MM/YYYY
let match = input.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:[ ,]+(\d{1,2}:\d{2}(?::\d{2})?))?$/);
if (match) {
let [ , d, m, y, t = "00:00:00", tzPart = "" ] = match;
const iso = `${y}-${m.padStart(2,'0')}-${d.padStart(2,'0')}T${t.length===5?t+":00":t}${tzPart}`;
return formatSafe(iso, tz);
let [, d, m, y, t = "00:00:00", tzPart = ""] = match;
const dNum = parseInt(d, 10);
const mNum = parseInt(m, 10);
if (dNum <= 12 && mNum > 12) {
} else {
const iso = `${y}-${m.padStart(2,'0')}-${d.padStart(2,'0')}T${t.length===5 ? t + ":00" : t}${tzPart}`;
return formatSafe(iso, tz);
}
}
// 3. US MM/DD/YYYY
// 3. US MM/DD/YYYY
match = input.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:[ ,]+(\d{1,2}:\d{2}(?::\d{2})?))?(.*)$/);
if (match) {
let [ , m, d, y, t = "00:00:00", tzPart = "" ] = match;
let [, m, d, y, t = "00:00:00", tzPart = ""] = match;
const iso = `${y}-${m.padStart(2,'0')}-${d.padStart(2,'0')}T${t.length===5?t+":00":t}${tzPart}`;
return formatSafe(iso, tz);
}
// 4. ISO-style (with T, Z, offsets)
match = input.match(/^(\d{4}-\d{1,2}-\d{1,2})[ T](\d{1,2}:\d{2}(?::\d{2})?)(Z|[+-]\d{2}:?\d{2})?$/);
// 4. ISO YYYY-MM-DD with optional Z/+offset
match = input.match(/^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[ T](\d{1,2}:\d{2}(?::\d{2})?)(Z|[+-]\d{2}:?\d{2})?$/);
if (match) {
let [ , ymd, time, offset = "" ] = match;
// normalize to YYYY-MM-DD
let [y, m, d] = ymd.split('-').map(x => x.padStart(2,'0'));
let [, y, m, d, time, offset = ""] = match;
const iso = `${y}-${m}-${d}T${time.length===5?time+":00":time}${offset}`;
return formatSafe(iso, tz);
}
// 5. RFC2822 / "25 Aug 2025 13:45:22 +0200"
// 5. RFC2822 / "25 Aug 2025 13:45:22 +0200"
match = input.match(/^\d{1,2} [A-Za-z]{3,} \d{4}/);
if (match) {
return formatSafe(input, tz);
}
// 6. Fallback (whatever Date() can parse)
// 6. DD-MM-YYYY with optional time
match = input.match(/^(\d{1,2})-(\d{1,2})-(\d{4})(?:[ T](\d{1,2}:\d{2}(?::\d{2})?))?$/);
if (match) {
let [, d, m, y, time = "00:00:00"] = match;
const iso = `${y}-${m.padStart(2,'0')}-${d.padStart(2,'0')}T${time.length===5?time+":00":time}`;
return formatSafe(iso, tz);
}
// 7. Strict YYYY-DD-MM with optional time
match = input.match(/^(\d{4})-(0[1-9]|[12]\d|3[01])-(0[1-9]|1[0-2])(?:[ T](\d{1,2}:\d{2}(?::\d{2})?))?$/);
if (match) {
let [, y, d, m, time = "00:00:00"] = match;
const iso = `${y}-${m}-${d}T${time.length === 5 ? time + ":00" : time}`;
return formatSafe(iso, tz);
}
// 8. Fallback
return formatSafe(input, tz);
function formatSafe(str, tz) {
@@ -440,6 +460,7 @@ function localizeTimestamp(input) {
}
// ----------------------------------------------------
/**
* Replaces double quotes within single-quoted strings, then converts all single quotes to double quotes,
@@ -1629,7 +1650,7 @@ async function executeOnce() {
await cacheSettings();
await cacheStrings();
console.log("All AJAX callbacks have completed");
console.log("All AJAX callbacks have completed");
onAllCallsComplete();
} catch (error) {
console.error("Error:", error);

View File

@@ -521,13 +521,17 @@ function getChildren(node, list, path, visited = [])
// Loop through all items to find children of the current node
for (var i in list) {
if (list[i].devParentMAC.toLowerCase() == node.devMac.toLowerCase() && !hiddenMacs.includes(list[i].devParentMAC)) {
const item = list[i];
const parentMac = item.devParentMAC || ""; // null-safe
const nodeMac = node.devMac || ""; // null-safe
visibleNodesCount++;
if (parentMac != "" && parentMac.toLowerCase() == nodeMac.toLowerCase() && !hiddenMacs.includes(parentMac)) {
// Process children recursively, passing a copy of the visited list
children.push(getChildren(list[i], list, path + ((path == "") ? "" : '|') + list[i].devParentMAC, visited));
}
visibleNodesCount++;
// Process children recursively, passing a copy of the visited list
children.push(getChildren(list[i], list, path + ((path == "") ? "" : '|') + parentMac, visited));
}
}
// Track leaf and parent node counts
@@ -565,14 +569,27 @@ function getChildren(node, list, path, visited = [])
// ---------------------------------------------------------------------------
function getHierarchy()
{
let internetNode = null;
for(i in deviceListGlobal)
{
if(deviceListGlobal[i].devMac == 'Internet')
{
return (getChildren(deviceListGlobal[i], deviceListGlobal, ''))
internetNode = deviceListGlobal[i];
return (getChildren(internetNode, deviceListGlobal, ''))
break;
}
}
if (!internetNode) {
showModalOk(
getString('Network_Configuration_Error'),
getString('Network_Root_Not_Configured')
);
console.error("getHierarchy(): Internet node not found");
return null;
}
}
//---------------------------------------------------------------------------
@@ -671,8 +688,6 @@ function handleNodeClick(el)
// ---------------------------------------------------------------------------
var myTree;
var emSize;
var nodeHeight;
// var sizeCoefficient = 1.4
@@ -689,140 +704,139 @@ function emToPx(em, element) {
function initTree(myHierarchy)
{
// calculate the drawing area based on teh tree width and available screen size
let baseFontSize = parseFloat($('html').css('font-size'));
let treeAreaHeight = ($(window).height() - 155); ;
// calculate the font size of the leaf nodes to fit everything into the tree area
leafNodesCount == 0 ? 1 : leafNodesCount;
emSize = pxToEm((treeAreaHeight/(leafNodesCount)).toFixed(2));
let screenWidthEm = pxToEm($('.networkTable').width()-15);
// init the drawing area size
$("#networkTree").attr('style', `height:${treeAreaHeight}px; width:${emToPx(screenWidthEm)}px`)
if(myHierarchy.type == "")
if(myHierarchy && myHierarchy.type !== "")
{
showModalOk(getString('Network_Configuration_Error'), getString('Network_Root_Not_Configured'))
// calculate the drawing area based on the tree width and available screen size
let baseFontSize = parseFloat($('html').css('font-size'));
let treeAreaHeight = ($(window).height() - 155); ;
return;
}
// calculate the font size of the leaf nodes to fit everything into the tree area
leafNodesCount == 0 ? 1 : leafNodesCount;
// handle canvas and node size if only a few nodes
emSize > 1 ? emSize = 1 : emSize = emSize;
emSize = pxToEm((treeAreaHeight/(leafNodesCount)).toFixed(2));
let nodeHeightPx = emToPx(emSize*1);
let nodeWidthPx = emToPx(screenWidthEm / (parentNodesCount));
let screenWidthEm = pxToEm($('.networkTable').width()-15);
// handle if only a few nodes
nodeWidthPx > 160 ? nodeWidthPx = 160 : nodeWidthPx = nodeWidthPx;
// init the drawing area size
$("#networkTree").attr('style', `height:${treeAreaHeight}px; width:${emToPx(screenWidthEm)}px`)
console.log(Treeviz);
// handle canvas and node size if only a few nodes
emSize > 1 ? emSize = 1 : emSize = emSize;
myTree = Treeviz.create({
htmlId: "networkTree",
renderNode: nodeData => {
let nodeHeightPx = emToPx(emSize*1);
let nodeWidthPx = emToPx(screenWidthEm / (parentNodesCount));
(!emptyArr.includes(nodeData.data.port )) ? port = nodeData.data.port : port = "";
// handle if only a few nodes
nodeWidthPx > 160 ? nodeWidthPx = 160 : nodeWidthPx = nodeWidthPx;
(port == "" || port == 0 || port == 'None' ) ? portBckgIcon = `<i class="fa fa-wifi"></i>` : portBckgIcon = `<i class="fa fa-ethernet"></i>`;
console.log(Treeviz);
portHtml = (port == "" || port == 0 || port == 'None' ) ? " &nbsp " : port;
myTree = Treeviz.create({
htmlId: "networkTree",
renderNode: nodeData => {
// Build HTML for individual nodes in the network diagram
deviceIcon = (!emptyArr.includes(nodeData.data.icon )) ?
`<div class="netIcon">
${atob(nodeData.data.icon)}
</div>` : "";
devicePort = `<div class="netPort"
style="width:${emSize}em;height:${emSize}em">
${portHtml}</div>
<div class="portBckgIcon"
style="margin-left:-${emSize*0.7}em;">
${portBckgIcon}
</div>`;
collapseExpandIcon = nodeData.data.hiddenChildren ?
"square-plus" : "square-minus";
(!emptyArr.includes(nodeData.data.port )) ? port = nodeData.data.port : port = "";
// generate +/- icon if node has children nodes
collapseExpandHtml = nodeData.data.hasChildren ?
`<div class="netCollapse"
style="font-size:${nodeHeightPx/2}px;top:${Math.floor(nodeHeightPx / 4)}px"
data-mytreepath="${nodeData.data.path}"
data-mytreemac="${nodeData.data.mac}">
<i class="fa fa-${collapseExpandIcon} pointer"></i>
</div>` : "";
(port == "" || port == 0 || port == 'None' ) ? portBckgIcon = `<i class="fa fa-wifi"></i>` : portBckgIcon = `<i class="fa fa-ethernet"></i>`;
selectedNodeMac = $(".nav-tabs-custom .active a").attr('data-mytabmac')
portHtml = (port == "" || port == 0 || port == 'None' ) ? " &nbsp " : port;
highlightedCss = nodeData.data.mac == selectedNodeMac ?
" highlightedNode " : "";
cssNodeType = nodeData.data.devIsNetworkNodeDynamic ?
" node-network-device " : " node-standard-device ";
// Build HTML for individual nodes in the network diagram
deviceIcon = (!emptyArr.includes(nodeData.data.icon )) ?
`<div class="netIcon">
${atob(nodeData.data.icon)}
</div>` : "";
devicePort = `<div class="netPort"
style="width:${emSize}em;height:${emSize}em">
${portHtml}</div>
<div class="portBckgIcon"
style="margin-left:-${emSize*0.7}em;">
${portBckgIcon}
</div>`;
collapseExpandIcon = nodeData.data.hiddenChildren ?
"square-plus" : "square-minus";
networkHardwareIcon = nodeData.data.devIsNetworkNodeDynamic ? `<span class="network-hw-icon">
<i class="fa-solid fa-hard-drive"></i>
</span>` : "";
// generate +/- icon if node has children nodes
collapseExpandHtml = nodeData.data.hasChildren ?
`<div class="netCollapse"
style="font-size:${nodeHeightPx/2}px;top:${Math.floor(nodeHeightPx / 4)}px"
data-mytreepath="${nodeData.data.path}"
data-mytreemac="${nodeData.data.mac}">
<i class="fa fa-${collapseExpandIcon} pointer"></i>
</div>` : "";
const badgeConf = getStatusBadgeParts(nodeData.data.presentLastScan, nodeData.data.alertDown, nodeData.data.mac, statusText = '')
selectedNodeMac = $(".nav-tabs-custom .active a").attr('data-mytabmac')
return result = `<div
class="node-inner hover-node-info box pointer ${highlightedCss} ${cssNodeType}"
style="height:${nodeHeightPx}px;font-size:${nodeHeightPx-5}px;"
onclick="handleNodeClick(this)"
data-mac="${nodeData.data.mac}"
data-parentMac="${nodeData.data.parentMac}"
data-name="${nodeData.data.name}"
data-ip="${nodeData.data.ip}"
data-mac="${nodeData.data.mac}"
data-vendor="${nodeData.data.vendor}"
data-type="${nodeData.data.type}"
data-devIsNetworkNodeDynamic="${nodeData.data.devIsNetworkNodeDynamic}"
data-lastseen="${nodeData.data.lastseen}"
data-firstseen="${nodeData.data.firstseen}"
data-relationship="${nodeData.data.relType}"
data-status="${nodeData.data.status}"
data-present="${nodeData.data.presentLastScan}"
data-alert="${nodeData.data.alertDown}"
data-icon="${nodeData.data.icon}"
>
<div class="netNodeText">
<strong><span>${devicePort} <span class="${badgeConf.cssText}">${deviceIcon}</span></span>
<span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${nodeData.data.name}</span>
${networkHardwareIcon}
</strong>
highlightedCss = nodeData.data.mac == selectedNodeMac ?
" highlightedNode " : "";
cssNodeType = nodeData.data.devIsNetworkNodeDynamic ?
" node-network-device " : " node-standard-device ";
networkHardwareIcon = nodeData.data.devIsNetworkNodeDynamic ? `<span class="network-hw-icon">
<i class="fa-solid fa-hard-drive"></i>
</span>` : "";
const badgeConf = getStatusBadgeParts(nodeData.data.presentLastScan, nodeData.data.alertDown, nodeData.data.mac, statusText = '')
return result = `<div
class="node-inner hover-node-info box pointer ${highlightedCss} ${cssNodeType}"
style="height:${nodeHeightPx}px;font-size:${nodeHeightPx-5}px;"
onclick="handleNodeClick(this)"
data-mac="${nodeData.data.mac}"
data-parentMac="${nodeData.data.parentMac}"
data-name="${nodeData.data.name}"
data-ip="${nodeData.data.ip}"
data-mac="${nodeData.data.mac}"
data-vendor="${nodeData.data.vendor}"
data-type="${nodeData.data.type}"
data-devIsNetworkNodeDynamic="${nodeData.data.devIsNetworkNodeDynamic}"
data-lastseen="${nodeData.data.lastseen}"
data-firstseen="${nodeData.data.firstseen}"
data-relationship="${nodeData.data.relType}"
data-status="${nodeData.data.status}"
data-present="${nodeData.data.presentLastScan}"
data-alert="${nodeData.data.alertDown}"
data-icon="${nodeData.data.icon}"
>
<div class="netNodeText">
<strong><span>${devicePort} <span class="${badgeConf.cssText}">${deviceIcon}</span></span>
<span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${nodeData.data.name}</span>
${networkHardwareIcon}
</strong>
</div>
</div>
</div>
${collapseExpandHtml}`;
},
mainAxisNodeSpacing: 'auto',
// secondaryAxisNodeSpacing: 0.3,
nodeHeight: nodeHeightPx,
nodeWidth: nodeWidthPx,
marginTop: '5',
isHorizontal : true,
hasZoom: true,
hasPan: true,
marginLeft: '10',
marginRight: '10',
idKey: "mac",
hasFlatData: false,
relationnalField: "children",
linkWidth: (nodeData) => 2,
linkColor: (nodeData) => {
relConf = getRelationshipConf(nodeData.data.relType)
return relConf.color;
}
// onNodeClick: (nodeData) => handleNodeClick(nodeData),
});
${collapseExpandHtml}`;
},
mainAxisNodeSpacing: 'auto',
// secondaryAxisNodeSpacing: 0.3,
nodeHeight: nodeHeightPx,
nodeWidth: nodeWidthPx,
marginTop: '5',
isHorizontal : true,
hasZoom: true,
hasPan: true,
marginLeft: '10',
marginRight: '10',
idKey: "mac",
hasFlatData: false,
relationnalField: "children",
linkWidth: (nodeData) => 2,
linkColor: (nodeData) => {
relConf = getRelationshipConf(nodeData.data.relType)
return relConf.color;
}
// onNodeClick: (nodeData) => handleNodeClick(nodeData),
});
console.log(deviceListGlobal);
myTree.refresh(myHierarchy);
console.log(deviceListGlobal);
myTree.refresh(myHierarchy);
// hide spinning icon
hideSpinner()
// hide spinning icon
hideSpinner()
} else
{
console.error("getHierarchy() not returning expected result");
}
}

View File

@@ -303,7 +303,7 @@ function saveSettings()
// save to the file
$new_name = $config_file.'_'.$timestamp.'.backup';
$new_location = $configFolderPath.$new_name;
$new_location = $configFolderPath.'/'.$new_name;
if(file_exists( $fullConfPath) != 1)
{

2
front/php/templates/language/fr_fr.json Executable file → Normal file
View File

@@ -311,7 +311,7 @@
"Gen_Filter": "Filtrer",
"Gen_Generate": "Générer",
"Gen_InvalidMac": "Adresse MAC invalide.",
"Gen_Invalid_Value": "",
"Gen_Invalid_Value": "Une valeur invalide a été renseignée",
"Gen_LockedDB": "Erreur - La base de données est peut-être verrouillée - Vérifier avec les outils de dév via F12 -> Console ou essayer plus tard.",
"Gen_NetworkMask": "Masque réseau",
"Gen_Offline": "Hors ligne",

File diff suppressed because it is too large Load Diff

View File

@@ -311,7 +311,7 @@
"Gen_Filter": "Фильтр",
"Gen_Generate": "Генерировать",
"Gen_InvalidMac": "Неверный Mac-адрес.",
"Gen_Invalid_Value": "",
"Gen_Invalid_Value": "Введено некорректное значение",
"Gen_LockedDB": "ОШИБКА - Возможно, база данных заблокирована. Проверьте инструменты разработчика F12 -> Консоль или повторите попытку позже.",
"Gen_NetworkMask": "Маска сети",
"Gen_Offline": "Оффлайн",

2
front/php/templates/language/uk_ua.json Executable file → Normal file
View File

@@ -311,7 +311,7 @@
"Gen_Filter": "Фільтр",
"Gen_Generate": "Генерувати",
"Gen_InvalidMac": "Недійсна Mac-адреса.",
"Gen_Invalid_Value": "",
"Gen_Invalid_Value": "Введено недійсне значення",
"Gen_LockedDB": "ПОМИЛКА БД може бути заблоковано перевірте F12 Інструменти розробника -> Консоль або спробуйте пізніше.",
"Gen_NetworkMask": "Маска мережі",
"Gen_Offline": "Офлайн",

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 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")

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 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"):

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 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()

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 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"):

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 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"):

View File

@@ -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 = []

View File

@@ -14,6 +14,14 @@ Specify the following settings in the Settings section of NetAlertX:
If unsure, please check [snmpwalk examples](https://www.comparitech.com/net-admin/snmpwalk-examples-windows-linux/).
Supported output formats:
```
ipNetToMediaPhysAddress[3][192.168.1.9] 6C:6C:6C:6C:6C:b6C1
IP-MIB::ipNetToMediaPhysAddress.17.10.10.3.202 = STRING: f8:81:1a:ef:ef:ef
mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4 32 18 61 43 "
```
### Setup Cisco IOS
Enable IOS SNMP service and restrict to selected (internal) IP/Subnet.

View File

@@ -30,7 +30,7 @@ RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log')
def main():
mylog('verbose', ['[SNMPDSC] In script '])
mylog('verbose', f"[{pluginName}] In script ")
# init global variables
global snmpWalkCmds
@@ -57,7 +57,7 @@ def main():
commands = [snmpWalkCmds]
for cmd in commands:
mylog('verbose', ['[SNMPDSC] Router snmpwalk command: ', cmd])
mylog('verbose', [f"[{pluginName}] Router snmpwalk command: ", cmd])
# split the string, remove white spaces around each item, and exclude any empty strings
snmpwalkArgs = [arg.strip() for arg in cmd.split(' ') if arg.strip()]
@@ -72,7 +72,7 @@ def main():
timeout=(timeoutSetting)
)
mylog('verbose', ['[SNMPDSC] output: ', output])
mylog('verbose', [f"[{pluginName}] output: ", output])
lines = output.split('\n')
@@ -80,6 +80,8 @@ def main():
tmpSplt = line.split('"')
# Expected Format:
# mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4 32 18 61 43 "
if len(tmpSplt) == 3:
ipStr = tmpSplt[0].split('.')[-4:] # Get the last 4 elements to extract the IP
@@ -89,7 +91,7 @@ def main():
macAddress = ':'.join(macStr)
ipAddress = '.'.join(ipStr)
mylog('verbose', [f'[SNMPDSC] IP: {ipAddress} MAC: {macAddress}'])
mylog('verbose', [f"[{pluginName}] IP: {ipAddress} MAC: {macAddress}"])
plugin_objects.add_object(
primaryId = handleEmpty(macAddress),
@@ -100,8 +102,40 @@ def main():
foreignKey = handleEmpty(macAddress) # Use the primary ID as the foreign key
)
else:
mylog('verbose', ['[SNMPDSC] ipStr does not seem to contain a valid IP:', ipStr])
mylog('verbose', [f"[{pluginName}] ipStr does not seem to contain a valid IP:", ipStr])
# Expected Format:
# IP-MIB::ipNetToMediaPhysAddress.17.10.10.3.202 = STRING: f8:81:1a:ef:ef:ef
elif "ipNetToMediaPhysAddress" in line and "=" in line and "STRING:" in line:
# Split on "=" → ["IP-MIB::ipNetToMediaPhysAddress.xxx.xxx.xxx.xxx ", " STRING: aa:bb:cc:dd:ee:ff"]
left, right = line.split("=", 1)
# Extract the MAC (right side)
macAddress = right.split("STRING:")[-1].strip()
macAddress = normalize_mac(macAddress)
# Extract IP address from the left side
# tail of the OID: last 4 integers = IPv4 address
oid_parts = left.strip().split('.')
ip_parts = oid_parts[-4:]
ipAddress = ".".join(ip_parts)
mylog('verbose', [f"[{pluginName}] (fallback) IP: {ipAddress} MAC: {macAddress}"])
plugin_objects.add_object(
primaryId = handleEmpty(macAddress),
secondaryId = handleEmpty(ipAddress),
watched1 = '(unknown)',
watched2 = handleEmpty(snmpwalkArgs[6]),
extra = handleEmpty(line),
foreignKey = handleEmpty(macAddress)
)
continue
# Expected Format:
# ipNetToMediaPhysAddress[3][192.168.1.9] 6C:6C:6C:6C:6C:b6C1
elif line.startswith('ipNetToMediaPhysAddress'):
# Format: snmpwalk -OXsq output
parts = line.split()
@@ -110,7 +144,7 @@ def main():
ipAddress = parts[0].split('[')[-1][:-1]
macAddress = normalize_mac(parts[1])
mylog('verbose', [f'[SNMPDSC] IP: {ipAddress} MAC: {macAddress}'])
mylog('verbose', [f"[{pluginName}] IP: {ipAddress} MAC: {macAddress}"])
plugin_objects.add_object(
primaryId = handleEmpty(macAddress),
@@ -121,7 +155,7 @@ def main():
foreignKey = handleEmpty(macAddress)
)
mylog('verbose', ['[SNMPDSC] Entries found: ', len(plugin_objects)])
mylog('verbose', [f"[{pluginName}] Entries found: ", len(plugin_objects)])
plugin_objects.write_result_file()

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

View File

@@ -14,7 +14,7 @@ if ! awk '$2 == "/" && $4 ~ /ro/ {found=1} END {exit !found}' /proc/mounts; then
══════════════════════════════════════════════════════════════════════════════
⚠️ Warning: Container is running as read-write, not in read-only mode.
Please mount the root filesystem as --read-only or use read-only: true
Please mount the root filesystem as --read-only or use read_only: true
https://github.com/jokob-sk/NetAlertX/blob/main/docs/docker-troubleshooting/read-only-filesystem.md
══════════════════════════════════════════════════════════════════════════════
EOF

View File

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

View File

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

View File

@@ -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={
@@ -87,22 +102,62 @@ CORS(
r"/dbquery/*": {"origins": "*"},
r"/messaging/*": {"origins": "*"},
r"/events/*": {"origins": "*"},
r"/logs/*": {"origins": "*"}
r"/logs/*": {"origins": "*"},
r"/api/tools/*": {"origins": "*"},
r"/auth/*": {"origins": "*"},
r"/mcp/*": {"origins": "*"}
},
supports_credentials=True,
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
# -------------------------------------------------------------------
@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)
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
@@ -125,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()
@@ -145,11 +200,12 @@ def graphql_endpoint():
return jsonify(response)
# Tools endpoints are registered via `mcp_endpoint.tools_bp` blueprint.
# --------------------------
# Settings Endpoints
# --------------------------
@app.route("/settings/<setKey>", methods=["GET"])
def api_get_setting(setKey):
if not is_authorized():
@@ -161,8 +217,7 @@ def api_get_setting(setKey):
# --------------------------
# Device Endpoints
# --------------------------
@app.route('/mcp/sse/device/<mac>', methods=['GET', 'POST'])
@app.route("/device/<mac>", methods=["GET"])
def api_get_device(mac):
if not is_authorized():
@@ -228,11 +283,45 @@ def api_update_device_column(mac):
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
# --------------------------
@app.route("/devices", methods=["GET"])
def api_get_devices():
if not is_authorized():
@@ -288,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():
@@ -298,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({"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
# --------------------------
@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)
@@ -367,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():
@@ -394,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)
@@ -459,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)
@@ -498,8 +700,6 @@ def api_add_to_execution_queue():
# --------------------------
# Device Events
# --------------------------
@app.route("/events/create/<mac>", methods=["POST"])
def api_create_event(mac):
if not is_authorized():
@@ -563,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/<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
# --------------------------
@@ -744,6 +983,23 @@ def sync_endpoint():
return jsonify({"success": False, "message": "ERROR: No allowed", "error": "Method Not Allowed"}), 405
# --------------------------
# Auth endpoint
# --------------------------
@app.route("/auth", methods=["GET"])
def check_auth():
if not is_authorized():
return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403
elif request.method == "GET":
return jsonify({"success": True, "message": "Authentication check successful"}), 200
else:
msg = "[sync endpoint] Method Not Allowed"
write_notification(msg, "alert")
mylog("verbose", [msg])
return jsonify({"success": False, "message": "ERROR: No allowed", "error": "Method Not Allowed"}), 405
# --------------------------
# Background Server Start
# --------------------------
@@ -775,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

View File

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

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

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 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,))

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
# tests/test_auth.py
import sys
import os
import pytest
# Register NetAlertX directories
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():
"""Load API token from system settings (same as other tests)."""
return get_setting_value("API_TOKEN")
@pytest.fixture
def client():
"""Flask test client."""
with app.test_client() as client:
yield client
def auth_headers(token):
return {"Authorization": f"Bearer {token}"}
# -------------------------
# AUTH ENDPOINT TESTS
# -------------------------
def test_auth_ok(client, api_token):
"""Valid token should allow access."""
resp = client.get("/auth", headers=auth_headers(api_token))
assert resp.status_code == 200
data = resp.get_json()
assert data is not None
assert data.get("success") is True
assert "successful" in data.get("message", "").lower()
def test_auth_missing_token(client):
"""Missing token should be forbidden."""
resp = client.get("/auth")
assert resp.status_code == 403
data = resp.get_json()
assert data is not None
assert data.get("success") is False
assert "not authorized" in data.get("message", "").lower()
def test_auth_invalid_token(client):
"""Invalid bearer token should be forbidden."""
resp = client.get("/auth", headers=auth_headers("INVALID-TOKEN"))
assert resp.status_code == 403
data = resp.get_json()
assert data is not None
assert data.get("success") is False
assert "not authorized" in data.get("message", "").lower()

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