diff --git a/docs/CUSTOM_PROPERTIES.md b/docs/CUSTOM_PROPERTIES.md index bcd7b9faf..8394a2a2a 100755 --- a/docs/CUSTOM_PROPERTIES.md +++ b/docs/CUSTOM_PROPERTIES.md @@ -35,10 +35,11 @@ Visible properties (`CUSTPROP_show: true`) are displayed as interactive icons in - Manage devices with actions like delete. - Example: Quickly remove a device from the network. -4. **Plugins**: +4. **Plugins (Experimental 🧪)**: - - Future placeholder for running custom plugin scripts. - - **Note**: Not implemented yet. + - Manually trigger an on-demand run of a plugin, regardless of its configured `RUN` schedule. + - Example: Add a button to re-run a scan-type plugin like `NMAPDEV` on demand. + - **Note**: The plugin runs with whatever settings/params it already has configured - it is not passed any device-specific or ad-hoc arguments. Some plugins require settings (credentials, target host, subnet, etc.) to be configured before they'll run properly; triggering an unconfigured plugin this way may fail or do nothing useful. --- @@ -56,6 +57,10 @@ Visible properties (`CUSTPROP_show: true`) are displayed as interactive icons in - Enable device removal functionality using `CUSTPROP_type: delete_dev`. +4. **Run a Plugin On Demand (Experimental 🧪)**: + + - Use `CUSTPROP_type: run_plugin` with `CUSTPROP_args` set to the target plugin's unique prefix (e.g. `NMAPDEV`) to add a button that triggers that plugin immediately. Make sure the plugin's own settings (credentials, target host, subnet, etc.) are already configured, since none are passed in via this action. + --- ## Defining Custom Properties @@ -97,14 +102,14 @@ Commonly useful fields: `devMac`, `devLastIP`, `devName`, `devVendor`, `devType` - **Link**: Redirects to a specified URL in the current browser tab. (**Arguments** Needs to contain the full URL.) - **Link (New Tab)**: Opens a specified URL in a new browser tab. (**Arguments** Needs to contain the full URL.) - **Delete Device**: Deletes the device using its MAC address. -- **Run Plugin**: Placeholder for executing custom plugins (not implemented yet). +- **Run Plugin (Experimental 🧪)**: Triggers an on-demand run of the plugin named in **Arguments** (its unique prefix, e.g. `NMAPDEV`), regardless of that plugin's configured `RUN` schedule. --- ## Notes -- **Plugin Functionality**: The `run_plugin` action type is currently not implemented and will show an alert if used. +- **Plugin Functionality (Experimental 🧪)**: `run_plugin` requires `CUSTPROP_args` to exactly match an enabled plugin's unique prefix (as listed in the `LOADED_PLUGINS` setting); an unrecognized or disabled prefix is rejected by the backend. It simply re-runs the plugin with its existing configuration - some plugins need required settings/params (e.g. credentials, target host, subnet) filled in first, or the run will fail or silently do nothing. Marked experimental until this is more clearly surfaced in the UI. - **Custom Icons (Experimental 🧪)**: Use Base64-encoded HTML to provide custom icons for each property. You can add your icons in Setttings via the `CUSTPROP_icon` settings - **Visibility Control**: Only properties with `CUSTPROP_show: true` will appear on the listing page. diff --git a/front/devices.php b/front/devices.php index 52a38fd12..453f21ed4 100755 --- a/front/devices.php +++ b/front/devices.php @@ -1308,7 +1308,7 @@ $(document).on('click', '.devicePropAction', function () { openInNewTab(args); break; case "run_plugin": - alert('Not implemented'); + runPlugin(args, name); break; case "delete_dev": askDeleteDeviceByMac(mac); diff --git a/front/js/device.js b/front/js/device.js index 7dbffa1c9..019fa8b00 100755 --- a/front/js/device.js +++ b/front/js/device.js @@ -43,4 +43,28 @@ function deleteDeviceByMac(mac) { }); } +// ----------------------------------------------------------------------------- +// Manually trigger an on-demand plugin run, regardless of its configured RUN +// schedule. Used by the "run_plugin" device custom property action, where +// `prefix` is the plugin's unique_prefix (e.g. NMAPDEV) supplied via CUSTPROP_args. +function runPlugin(prefix, name) { + const apiBase = getApiBase(); + const apiToken = getSetting("API_TOKEN"); + const url = `${apiBase}/plugin/${encodeURIComponent(prefix)}/run`; + const label = name || prefix; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + success: function(response) { + showMessage(response.success ? `Run triggered for ${label}` : (response.error || "Unknown error")); + }, + error: function(xhr, status, error) { + console.error("Error running plugin:", status, error); + showMessage("Error: " + (xhr.responseJSON?.error || error)); + } + }); +} + diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index c214463d4..8460a30e2 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -2089,6 +2089,36 @@ def api_plugin_stats(payload=None): return jsonify({"success": True, "data": data}) +# -------------------------- +# Plugin Run endpoint +# -------------------------- +@app.route("/plugin//run", methods=["POST"]) +@validate_request( + operation_id="run_plugin", + summary="Run Plugin", + description="Manually trigger an on-demand run of a plugin by its unique prefix, regardless of its " + "configured RUN schedule (e.g. used by the 'Run Plugin' device custom property action).", + path_params=[{ + "name": "prefix", + "description": "Plugin unique prefix (e.g. NMAPDEV, ARPSCAN)", + "schema": {"type": "string"} + }], + response_model=BaseResponse, + tags=["plugins"], + validation_error_code=400, + auth_callable=is_authorized +) +def api_run_plugin(prefix, payload=None): + loaded_plugins = get_setting_value('LOADED_PLUGINS') + if prefix not in loaded_plugins: + return jsonify({"success": False, "error": f"Invalid plugin. Must be one of: {', '.join(loaded_plugins)}"}), 400 + + queue = UserEventsQueueInstance() + queue.add_event(f"run|{prefix}") + + return jsonify({"success": True, "message": f"Run triggered for plugin: {prefix}"}), 200 + + # -------------------------- # Background Server Start # -------------------------- diff --git a/test/api_endpoints/test_plugin_run_endpoint.py b/test/api_endpoints/test_plugin_run_endpoint.py new file mode 100644 index 000000000..8aedacb4f --- /dev/null +++ b/test/api_endpoints/test_plugin_run_endpoint.py @@ -0,0 +1,67 @@ +"""Tests for /plugin//run endpoint.""" + +import sys +import os +import pytest +from unittest.mock import patch, MagicMock + +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/server/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}"} + + +def test_run_plugin_unauthorized(client): + """Missing token should be forbidden.""" + resp = client.post("/plugin/ARPSCAN/run") + assert resp.status_code == 403 + assert resp.get_json().get("success") is False + + +@patch("api_server.api_server_start.UserEventsQueueInstance") +def test_run_plugin_success(mock_queue_class, client, api_token): + """A known, loaded plugin prefix queues a run event.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + loaded_plugins = get_setting_value("LOADED_PLUGINS") + prefix = loaded_plugins[0] + + resp = client.post(f"/plugin/{prefix}/run", headers=auth_headers(api_token)) + + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + mock_queue.add_event.assert_called_once() + call_args = mock_queue.add_event.call_args[0] + assert f"run|{prefix}" in call_args[0] + + +@patch("api_server.api_server_start.UserEventsQueueInstance") +def test_run_plugin_invalid_prefix(mock_queue_class, client, api_token): + """An unknown plugin prefix is rejected before touching the queue.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + resp = client.post("/plugin/NOT_A_REAL_PLUGIN/run", headers=auth_headers(api_token)) + + assert resp.status_code == 400 + data = resp.get_json() + assert data["success"] is False + mock_queue.add_event.assert_not_called()