PLG: PIHOLEAPI #1282

Signed-off-by: jokob-sk <jokob.sk@gmail.com>
This commit is contained in:
jokob-sk
2025-11-15 13:36:22 +11:00
parent ec417b0dac
commit 972654dc78
3 changed files with 904 additions and 0 deletions

View File

@@ -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-holes 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://<pi-hole-ip>/
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-holes own UI first.
### Notes
- Additional notes, limitations, Author info.
- Version: 1.0.0
- Author: `jokob-sk`, `leiweibau`
- Release Date: `11-2025`
---

View File

@@ -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": "<i class=\"fa fa-search\"></i>"
}
],
"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 <code>always_after_scan</code>, <code>schedule</code>."
}
]
},
{
"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 <code>schedule</code> in the <a href=\"#SYNC_RUN\"><code>SYNC_RUN</code> setting</a>. Make sure you enter the schedule in the correct cron-like format (e.g. validate at <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>). For example entering <code>0 4 * * *</code> will run the scan after 4 am in the <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code> you set above</a>. 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 <code>http://pi.hole:8080/</code>"
}
]
},
{
"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": "<div style='text-align:center'><i class='fa-solid fa-square-check'></i><div></div>"
},
{
"equals": "watched-changed",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-triangle-exclamation'></i></div>"
},
{
"equals": "new",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-circle-plus'></i></div>"
},
{
"equals": "missing-in-last-scan",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-question'></i></div>"
}
],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Status"
}
]
}
]
}

View File

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