diff --git a/front/plugins/pihole_api_scan/README.md b/front/plugins/pihole_api_scan/README.md new file mode 100644 index 00000000..e38750df --- /dev/null +++ b/front/plugins/pihole_api_scan/README.md @@ -0,0 +1,133 @@ +## Overview - PIHOLEAPI Plugin — Pi-hole v6 Device Import + +The **PIHOLEAPI** plugin lets NetAlertX import network devices directly from a **Pi-hole v6** instance. +This turns Pi-hole into an additional discovery source, helping NetAlertX stay aware of devices seen by your DNS server. + +The plugin connects to your Pi-hole’s API and retrieves: + +* MAC addresses +* IP addresses +* Hostnames (if available) +* Vendor info +* Last-seen timestamps + +NetAlertX then uses this information to match or create devices in your system. + +> [!TIP] +> Some tip. + +### Quick setup guide + +* You are running **Pi-hole v6** or newer. +* The Web UI password in **Pi-hole** is set. +* Local network devices appear under **Settings → Network** in Pi-hole. + +No additional Pi-hole configuration is required. + +### Usage + +- Head to **Settings** > **Plugin name** to adjust the default values. + +| Setting Key | Description | +| ---------------------------- | -------------------------------------------------------------------------------- | +| **PIHOLEAPI_URL** | Your Pi-hole base URL. | +| **PIHOLEAPI_PASSWORD** | The Web UI base64 encoded (en-/decoding handled by the app) admin password. | +| **PIHOLEAPI_SSL_VERIFY** | Whether to verify HTTPS certificates. Disable only for self-signed certificates. | +| **PIHOLEAPI_RUN_TIMEOUT** | Request timeout in seconds. | +| **PIHOLEAPI_API_MAXCLIENTS** | Maximum number of devices to request from Pi-hole. Defaults are usually fine. | + +### Example Configuration + +| Setting Key | Sample Value | +| ---------------------------- | -------------------------------------------------- | +| **PIHOLEAPI_URL** | `http://pi.hole/` | +| **PIHOLEAPI_PASSWORD** | `passw0rd` | +| **PIHOLEAPI_SSL_VERIFY** | `true` | +| **PIHOLEAPI_RUN_TIMEOUT** | `30` | +| **PIHOLEAPI_API_MAXCLIENTS** | `500` | + +### ⚠️ Troubleshooting + +Below are the most common issues and how to resolve them. + +--- + +#### ❌ Authentication failed + +Check the following: + +* The Pi-hole URL is correct and includes a trailing slash + + * `http://192.168.1.10/` ✔ + * `http://192.168.1.10/admin` ❌ +* Your Pi-hole password is correct +* You are using **Pi-hole v6**, not v5 +* SSL verification matches your setup (disable for self-signed certificates) + +--- + +#### ❌ Connection error + +Usually caused by: + +* Wrong URL +* Wrong HTTP/HTTPS selection +* Timeout too low + +Try: + +``` +PIHOLEAPI_URL = http:/// +PIHOLEAPI_RUN_TIMEOUT = 60 +``` + +--- + +#### ❌ No devices imported + +Check: + +* Pi-hole shows devices under **Settings → Network** +* NetAlertX logs contain: + +``` +[PIHOLEAPI] Pi-hole API returned data +``` + +If nothing appears: + +* Pi-hole might be returning empty results +* Your network interface list may be empty +* A firewall or reverse proxy is blocking access + +Try enabling debug logging: + +``` +LOG_LEVEL = debug +``` + +Then re-run the plugin. + +--- + +#### ❌ Wrong or missing hostnames + +Pi-hole only reports names it knows from: + +* Local DNS +* DHCP leases +* Previously seen queries + +If names are missing, confirm they appear in Pi-hole’s own UI first. + +### Notes + +- Additional notes, limitations, Author info. + +- Version: 1.0.0 +- Author: `jokob-sk`, `leiweibau` +- Release Date: `11-2025` + +--- + + diff --git a/front/plugins/pihole_api_scan/config.json b/front/plugins/pihole_api_scan/config.json new file mode 100644 index 00000000..3f9fb76f --- /dev/null +++ b/front/plugins/pihole_api_scan/config.json @@ -0,0 +1,476 @@ +{ + "code_name": "pihole_api_scan", + "unique_prefix": "PIHOLEAPI", + "plugin_type": "device_scanner", + "execution_order" : "Layer_0", + "enabled": true, + "data_source": "script", + "mapped_to_table": "CurrentScan", + "data_filters": [ + { + "compare_column": "Object_PrimaryID", + "compare_operator": "==", + "compare_field_id": "txtMacFilter", + "compare_js_template": "'{value}'.toString()", + "compare_use_quotes": true + } + ], + "show_ui": true, + "localized": ["display_name", "description", "icon"], + "display_name": [ + { + "language_code": "en_us", + "string": "PiHole API scan" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Imports devices from PiHole via APIv6" + } + ], + "icon": [ + { + "language_code": "en_us", + "string": "" + } + ], + "params": [], + "settings": [ + { + "function": "RUN", + "events": ["run"], + "type": { + "dataType": "string", + "elements": [ + { "elementType": "select", "elementOptions": [], "transformers": [] } + ] + }, + + "default_value": "disabled", + "options": [ + "disabled", + "once", + "schedule", + "always_after_scan" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "When to run" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "When the plugin should run. Good options are always_after_scan, schedule." + } + ] + }, + { + "function": "RUN_SCHD", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "span", + "elementOptions": [ + { + "cssClasses": "input-group-addon validityCheck" + }, + { + "getStringKey": "Gen_ValidIcon" + } + ], + "transformers": [] + }, + { + "elementType": "input", + "elementOptions": [ + { + "onChange": "validateRegex(this)" + }, + { + "base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlsxLTldfFsxMl1bMC05XXwzWzAxXXxbMC05XSstWzAtOV0rfFwqL1swLTldKykpXHMrKD86XCp8KD86WzEtOV18MVswLTJdfFswLTldKy1bMC05XSt8XCovWzAtOV0rKSlccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XXxcKi9bMC05XSspKSQ=" + } + ], + "transformers": [] + } + ] + }, + "default_value": "*/5 * * * *", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Schedule" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Only enabled if you select schedule in the SYNC_RUN setting. Make sure you enter the schedule in the correct cron-like format (e.g. validate at crontab.guru). For example entering 0 4 * * * will run the scan after 4 am in the TIMEZONE you set above. Will be run NEXT time the time passes." + } + ] + }, + { + "function": "URL", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "maxLength": 50, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Setting name" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "URL to your PiHole instance, for example http://pi.hole:8080/" + } + ] + }, + { + "function": "PASSWORD", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "password" }], + "transformers": [] + } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Password" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "PiHole WEB UI password." + } + ] + }, + { + "function": "VERIFY_SSL", + "type": { + "dataType": "boolean", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "checkbox" }], + "transformers": [] + } + ] + }, + "default_value": false, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Verify SSL" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Enable TLS support. Disable if you are using a self-signed certificate." + } + ] + }, + { + "function": "API_MAXCLIENTS", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "number" }], + "transformers": [] + } + ] + }, + "default_value": 500, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Max Clients" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Maximum number of devices to import." + } + ] + }, + { + "function": "CMD", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "readonly": "true" }], + "transformers": [] + } + ] + }, + "default_value": "python3 /app/front/plugins/pihole_api_scan/pihole_api_scan.py", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Command" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Command to run. This can not be changed" + } + ] + }, + { + "function": "RUN_TIMEOUT", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "number" }], + "transformers": [] + } + ] + }, + "default_value": 30, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Run timeout" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." + } + ] + } + ], + "database_column_definitions": [ + { + "column": "Index", + "css_classes": "col-sm-2", + "show": true, + "type": "none", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Index" + } + ] + }, + { + "column": "Object_PrimaryID", + "mapped_to_column": "cur_MAC", + "css_classes": "col-sm-3", + "show": true, + "type": "device_name_mac", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "MAC (name)" + } + ] + }, + { + "column": "Object_SecondaryID", + "mapped_to_column": "cur_IP", + "css_classes": "col-sm-2", + "show": true, + "type": "device_ip", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "IP" + } + ] + }, + { + "column": "Watched_Value1", + "mapped_to_column": "cur_Name", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Name" + } + ] + }, + { + "column": "Watched_Value2", + "mapped_to_column": "cur_Vendor", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Vendor" + } + ] + }, + { + "column": "Watched_Value3", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Last Query" + } + ] + }, + { + "column": "Watched_Value4", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + } + ] + }, + { + "column": "Dummy", + "mapped_to_column": "cur_ScanMethod", + "mapped_to_column_data": { + "value": "PIHOLEAPI" + }, + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Scan method" + } + ] + }, + { + "column": "DateTimeCreated", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Created" + } + ] + }, + { + "column": "DateTimeChanged", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Changed" + } + ] + }, + { + "column": "Status", + "css_classes": "col-sm-1", + "show": true, + "type": "replace", + "default_value": "", + "options": [ + { + "equals": "watched-not-changed", + "replacement": "
" + }, + { + "equals": "watched-changed", + "replacement": "
" + }, + { + "equals": "new", + "replacement": "
" + }, + { + "equals": "missing-in-last-scan", + "replacement": "
" + } + ], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Status" + } + ] + } + ] +} diff --git a/front/plugins/pihole_api_scan/pihole_api_scan.py b/front/plugins/pihole_api_scan/pihole_api_scan.py new file mode 100644 index 00000000..d654786b --- /dev/null +++ b/front/plugins/pihole_api_scan/pihole_api_scan.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python +""" +NetAlertX plugin: PIHOLEAPI +Imports devices from Pi-hole v6 API (Network endpoints) into NetAlertX plugin results. +""" + +import os +import sys +import datetime +import requests +import json +from requests.packages.urllib3.exceptions import InsecureRequestWarning + +# --- NetAlertX plugin bootstrap (match example) --- +INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +pluginName = 'PIHOLEAPI' + +from plugin_helper import Plugin_Objects +from logger import mylog, Logger +from helper import get_setting_value +from const import logPath +import conf +from pytz import timezone + +# Setup timezone & logger using standard NAX helpers +conf.tz = timezone(get_setting_value('TIMEZONE')) +Logger(get_setting_value('LOG_LEVEL')) + +LOG_PATH = logPath + '/plugins' +RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log') + +plugin_objects = Plugin_Objects(RESULT_FILE) + +# --- Global state for session --- +PIHOLEAPI_URL = None +PIHOLEAPI_PASSWORD = None +PIHOLEAPI_SES_VALID = False +PIHOLEAPI_SES_SID = None +PIHOLEAPI_SES_CSRF = None +PIHOLEAPI_API_MAXCLIENTS = None +PIHOLEAPI_VERIFY_SSL = True +PIHOLEAPI_RUN_TIMEOUT = 10 +VERSION_DATE = "NAX-PIHOLEAPI-1.0" + + +# ------------------------------------------------------------------ +def pihole_api_auth(): + """Authenticate to Pi-hole v6 API and populate session globals.""" + + global PIHOLEAPI_SES_VALID, PIHOLEAPI_SES_SID, PIHOLEAPI_SES_CSRF + + if not PIHOLEAPI_URL: + mylog('none', [f'[{pluginName}] PIHOLEAPI_URL not configured — skipping.']) + return False + + # handle SSL verification setting - disable insecure warnings only when PIHOLEAPI_VERIFY_SSL=False + if not PIHOLEAPI_VERIFY_SSL: + requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + + headers = { + "accept": "application/json", + "content-type": "application/json", + "User-Agent": "NetAlertX/" + VERSION_DATE + } + data = {"password": PIHOLEAPI_PASSWORD} + + try: + resp = requests.post(PIHOLEAPI_URL + 'api/auth', headers=headers, json=data, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT) + resp.raise_for_status() + except requests.exceptions.Timeout: + mylog('none', [f'[{pluginName}] Pi-hole auth request timed out. Try increasing PIHOLEAPI_RUN_TIMEOUT.']) + return False + except requests.exceptions.ConnectionError: + mylog('none', [f'[{pluginName}] Connection error during Pi-hole auth. Check PIHOLEAPI_URL and PIHOLEAPI_PASSWORD']) + return False + except Exception as e: + mylog('none', [f'[{pluginName}] Unexpected auth error: {e}']) + return False + + try: + response_json = resp.json() + except Exception: + mylog('none', [f'[{pluginName}] Unable to parse Pi-hole auth response JSON.']) + return False + + session_data = response_json.get('session', {}) + + if session_data.get('valid', False): + PIHOLEAPI_SES_VALID = True + PIHOLEAPI_SES_SID = session_data.get('sid') + # csrf might not be present if no password set + PIHOLEAPI_SES_CSRF = session_data.get('csrf') + mylog('verbose', [f'[{pluginName}] Authenticated to Pi-hole (sid present).']) + return True + else: + mylog('none', [f'[{pluginName}] Pi-hole auth required or failed.']) + return False + + +# ------------------------------------------------------------------ +def pihole_api_deauth(): + """Logout from Pi-hole v6 API (best-effort).""" + global PIHOLEAPI_SES_VALID, PIHOLEAPI_SES_SID, PIHOLEAPI_SES_CSRF + + if not PIHOLEAPI_URL: + return + if not PIHOLEAPI_SES_SID: + return + + headers = {"X-FTL-SID": PIHOLEAPI_SES_SID} + try: + requests.delete(PIHOLEAPI_URL + 'api/auth', headers=headers, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT) + except Exception: + # ignore errors on logout + pass + PIHOLEAPI_SES_VALID = False + PIHOLEAPI_SES_SID = None + PIHOLEAPI_SES_CSRF = None + + +# ------------------------------------------------------------------ +def get_pihole_interface_data(): + """Return dict mapping mac -> [ipv4 addresses] from Pi-hole interfaces endpoint.""" + + result = {} + if not PIHOLEAPI_SES_VALID: + return result + + headers = {"X-FTL-SID": PIHOLEAPI_SES_SID} + if PIHOLEAPI_SES_CSRF: + headers["X-FTL-CSRF"] = PIHOLEAPI_SES_CSRF + + try: + resp = requests.get(PIHOLEAPI_URL + 'api/network/interfaces', headers=headers, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT) + resp.raise_for_status() + data = resp.json() + except Exception as e: + mylog('none', [f'[{pluginName}] Failed to fetch Pi-hole interfaces: {e}']) + return result + + for interface in data.get('interfaces', []): + mac_address = interface.get('address') + if not mac_address or mac_address == "00:00:00:00:00:00": + continue + addrs = [] + for addr in interface.get('addresses', []): + if addr.get('family') == 'inet': + a = addr.get('address') + if a: + addrs.append(a) + if addrs: + result[mac_address] = addrs + return result + + +# ------------------------------------------------------------------ +def get_pihole_network_devices(): + """Return list of devices from Pi-hole v6 API (devices endpoint).""" + + devices = [] + + # return empty list if no session available + if not PIHOLEAPI_SES_VALID: + return devices + + # prepare headers + headers = {"X-FTL-SID": PIHOLEAPI_SES_SID} + if PIHOLEAPI_SES_CSRF: + headers["X-FTL-CSRF"] = PIHOLEAPI_SES_CSRF + + params = { + 'max_devices': str(PIHOLEAPI_API_MAXCLIENTS), + 'max_addresses': '2' + } + + try: + resp = requests.get(PIHOLEAPI_URL + 'api/network/devices', headers=headers, params=params, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT) + resp.raise_for_status() + data = resp.json() + + mylog('debug', [f'[{pluginName}] Pi-hole API returned data: {json.dumps(data)}']) + + except Exception as e: + mylog('none', [f'[{pluginName}] Failed to fetch Pi-hole devices: {e}']) + return devices + + # The API returns 'devices' list + return data.get('devices', []) + + +# ------------------------------------------------------------------ +def gather_device_entries(): + """ + Build a list of device entries suitable for Plugin_Objects.add_object. + Each entry is a dict with: mac, ip, name, macVendor, lastQuery + """ + entries = [] + + iface_map = get_pihole_interface_data() + devices = get_pihole_network_devices() + now_ts = int(datetime.datetime.now().timestamp()) + + for device in devices: + hwaddr = device.get('hwaddr') + if not hwaddr or hwaddr == "00:00:00:00:00:00": + continue + + macVendor = device.get('macVendor', '') + lastQuery = device.get('lastQuery') + # 'ips' is a list of dicts: {ip, name} + for ip_info in device.get('ips', []): + ip = ip_info.get('ip') + if not ip: + continue + + name = ip_info.get('name') or '(unknown)' + + # mark active if ip present on local interfaces + for mac, iplist in iface_map.items(): + if ip in iplist: + lastQuery = str(now_ts) + + entries.append({ + 'mac': hwaddr.lower(), + 'ip': ip, + 'name': name, + 'macVendor': macVendor, + 'lastQuery': str(lastQuery) if lastQuery is not None else '' + }) + + return entries + + +# ------------------------------------------------------------------ +def main(): + """Main plugin entrypoint.""" + global PIHOLEAPI_URL, PIHOLEAPI_PASSWORD, PIHOLEAPI_API_MAXCLIENTS, PIHOLEAPI_VERIFY_SSL, PIHOLEAPI_RUN_TIMEOUT + + mylog('verbose', [f'[{pluginName}] start script.']) + + # Load settings from NAX config + PIHOLEAPI_URL = get_setting_value('PIHOLEAPI_URL') + + # ensure trailing slash + if not PIHOLEAPI_URL.endswith('/'): + PIHOLEAPI_URL += '/' + + PIHOLEAPI_PASSWORD = get_setting_value('PIHOLEAPI_PASSWORD') + PIHOLEAPI_API_MAXCLIENTS = get_setting_value('PIHOLEAPI_API_MAXCLIENTS') + # Accept boolean or string "True"/"False" + PIHOLEAPI_VERIFY_SSL = get_setting_value('PIHOLEAPI_SSL_VERIFY') + PIHOLEAPI_RUN_TIMEOUT = get_setting_value('PIHOLEAPI_RUN_TIMEOUT') + + # Authenticate + if not pihole_api_auth(): + mylog('none', [f'[{pluginName}] Authentication failed — no devices imported.']) + return 1 + + try: + device_entries = gather_device_entries() + + if not device_entries: + mylog('verbose', [f'[{pluginName}] No devices found on Pi-hole.']) + else: + for entry in device_entries: + + # Map to Plugin_Objects fields + mylog('verbose', [f'[{pluginName}] found: {entry['name']}|{entry['mac']}|{entry['ip']}']) + + plugin_objects.add_object( + primaryId=str(entry['mac']), + secondaryId=str(entry['ip']), + watched1=str(entry['name']), + watched2=str(entry['macVendor']), + watched3=str(entry['lastQuery']), + watched4="", + extra=pluginName, + foreignKey=str(entry['mac']) + ) + + # Write result file for NetAlertX to ingest + plugin_objects.write_result_file() + mylog('verbose', [f'[{pluginName}] Script finished. Imported {len(device_entries)} entries.']) + + finally: + # Deauth best-effort + pihole_api_deauth() + + return 0 + + +if __name__ == '__main__': + main()