commit 72a2819aea5911afcde43647f1d347f027eebb8f Author: Spencer Grimes Date: Thu Aug 14 14:56:56 2025 -0500 Connection Succesful, working on filtering diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..726e4ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.env +config.json +npm-debug.log +GEMINI.md +PROGRESS.md +SPEC.md +.git +package-lock.json +combined.log +error.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3232d8 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Pi-hole DNS to Home Assistant Connector + +This application acts as a middleman between a Pi-hole instance and a Home Assistant instance. It periodically queries Pi-hole for blocked DNS queries that match a user-defined list of strings. When a certain threshold of matches is met within a time frame, it triggers a specific Home Assistant automation. + +## Features + +- Polls Pi-hole for DNS queries. +- Filters queries based on a list of strings. +- Implements a "point" system for matched queries with a 5-minute expiration. +- Triggers a Home Assistant automation when a point threshold is reached. + +## Prerequisites + +- Node.js (v14 or higher) +- A running Pi-hole instance +- A running Home Assistant instance + +## Installation + +1. Clone this repository or download the source code. +2. Navigate to the project directory. +3. Install the dependencies: + ```bash + npm install + ``` + +## Configuration + +1. Rename `config.json.example` to `config.json`. +2. Edit `config.json` with your specific settings: + + - `pihole.host`: The IP address of your Pi-hole. + - `pihole.password`: Your Pi-hole web interface password. This is required for Pi-hole v6 and newer. If your Pi-hole has no password, you can leave this as an empty string (`""`). + - `homeAssistant.host`: The IP address and port of your Home Assistant instance (e.g., `192.168.1.200:8123`). + - `homeAssistant.apiToken`: Your Home Assistant Long-Lived Access Token. + - `homeAssistant.automation`: The entity ID of the Home Assistant automation to trigger. + - `filter.strings`: An array of strings to match against blocked DNS queries. + - `pollingIntervalSeconds`: The interval in seconds to poll Pi-hole. + - `pointsThreshold`: The number of points required to trigger the Home Assistant automation. + + - `notifications.discordWebhookUrl`: (Optional) A Discord webhook URL to send messages to when a point is added. Leave empty or omit if not using Discord notifications. + +## Usage + +To run the application: + +```bash +node src/app.js +``` + +## Logging + +The application generates two log files: + +- `error.log`: Contains only error messages. +- `combined.log`: Contains all log messages. diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..8d4cd84 --- /dev/null +++ b/config.json.example @@ -0,0 +1,24 @@ +{ + "pihole": { + "host": "192.168.1.100", + "password": "YOUR_PIHOLE_PASSWORD" + }, + "homeAssistant": { + "host": "192.168.1.200:8123", + "apiToken": "YOUR_HOME_ASSISTANT_API_TOKEN", + "automation": "automation.trigger_pihole_notification" + }, + "filter": { + "strings": [ + "example.com", + "test.net", + "linuxfoundation.org" + ] + }, + "pollingIntervalSeconds": 10, + "pointsThreshold": 2, + "pointExpirationSeconds": 300, + "notifications": { + "discordWebhookUrl": "YOUR_DISCORD_WEBHOOK_URL" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7621e24 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "piholedns_ha_connector", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "axios": "^1.11.0", + "winston": "^3.17.0" + } +} diff --git a/pihole-ha-connector.service.example b/pihole-ha-connector.service.example new file mode 100644 index 0000000..e481d72 --- /dev/null +++ b/pihole-ha-connector.service.example @@ -0,0 +1,13 @@ +[Unit] +Description=Pi-hole to Home Assistant Connector +After=network.target + +[Service] +Type=simple +User=your_username +WorkingDirectory=/path/to/your/PiholeDNS_HA_Connector +ExecStart=/usr/bin/node src/app.js +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..62c64dd --- /dev/null +++ b/src/app.js @@ -0,0 +1,89 @@ +const config = require('./config'); +const winston = require('winston'); +const { getAllQueries } = require('./pihole'); +const { filterQueries, addPoint, getPointsCount, checkAndExpirePoints, clearPoints } = require('./points'); +const { triggerAutomation } = require('./homeAssistant'); +const discord = require('./discord'); + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.Console({ format: winston.format.simple() }), + new winston.transports.File({ filename: 'error.log', level: 'error' }), + new winston.transports.File({ filename: 'combined.log' }), + ], +}); + +function validateConfig(config) { + if (!config.pihole || !config.pihole.host) { + throw new Error('Pi-hole configuration is missing host.'); + } + // Password is now optional, so we don't validate it here. + + if (!config.homeAssistant || !config.homeAssistant.host || !config.homeAssistant.apiToken || !config.homeAssistant.automation) { + throw new Error('Home Assistant configuration is missing or incomplete.'); + } + if (!config.filter || !Array.isArray(config.filter.strings)) { + throw new Error('Filter configuration is missing or invalid.'); + } + if (config.pointExpirationSeconds === undefined) { + config.pointExpirationSeconds = 300; // Default to 5 minutes + logger.warn('pointExpirationSeconds not set in config, defaulting to 300.'); + } + + // Validate Discord webhook URL if provided + if (config.notifications && config.notifications.discordWebhookUrl) { + try { + new URL(config.notifications.discordWebhookUrl); + } catch (e) { + throw new Error('Invalid Discord webhook URL provided.'); + } + } + + logger.info('Configuration loaded and validated successfully.'); +} + +try { + validateConfig(config); +} catch (error) { + logger.error('Configuration validation failed:', { message: error.message }); + process.exit(1); +} + +async function main() { + logger.info('Starting Pi-hole to Home Assistant Connector'); + + setInterval(async () => { + logger.info('Running polling check...'); + + checkAndExpirePoints(logger); + + const queries = await getAllQueries(config, logger); + + if (queries) { + console.log('DEBUG: Queries Data:', JSON.stringify(queries.data, null, 2)); + console.log('DEBUG: Filter Strings:', JSON.stringify(config.filter.strings, null, 2)); + const matchingDomains = filterQueries(queries, config.filter.strings); + + if (matchingDomains.size > 0) { + logger.info(`Found ${matchingDomains.size} matching domains.`); + for (const domain of matchingDomains) { + addPoint(domain, config.pointExpirationSeconds, logger, config, discord); + } + } + + const currentPoints = getPointsCount(); + if (currentPoints >= config.pointsThreshold) { + logger.info(`Point threshold of ${config.pointsThreshold} reached (${currentPoints} points). Triggering Home Assistant automation.`); + await triggerAutomation(config, logger); + clearPoints(logger); + } + } + }, config.pollingIntervalSeconds * 1000); +} + +main(); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..8f9f13b --- /dev/null +++ b/src/config.js @@ -0,0 +1,17 @@ +const fs = require('fs'); +const path = require('path'); + +const configPath = path.join(__dirname, '..', 'config.json'); + +let config; + +try { + const rawConfig = fs.readFileSync(configPath, 'utf8'); + config = JSON.parse(rawConfig); +} catch (error) { + console.error('Error reading or parsing config.json:', error); + console.error('Please ensure a valid config.json file exists in the root directory.'); + process.exit(1); +} + +module.exports = config; diff --git a/src/discord.js b/src/discord.js new file mode 100644 index 0000000..3ab1c51 --- /dev/null +++ b/src/discord.js @@ -0,0 +1,17 @@ +const axios = require('axios'); + +async function sendMessage(webhookUrl, message, logger) { + if (!webhookUrl) { + logger.warn('Discord webhook URL is not configured. Skipping message.'); + return; + } + + try { + await axios.post(webhookUrl, { content: message }); + logger.info('Discord message sent successfully.'); + } catch (error) { + logger.error('Error sending Discord message:', { message: error.message, webhookUrl }); + } +} + +module.exports = { sendMessage }; diff --git a/src/homeAssistant.js b/src/homeAssistant.js new file mode 100644 index 0000000..bd3eee2 --- /dev/null +++ b/src/homeAssistant.js @@ -0,0 +1,22 @@ +const axios = require('axios'); + +async function triggerAutomation(config, logger) { + const haApiUrl = `http://${config.homeAssistant.host}/api/services/automation/trigger`; + try { + await axios.post( + haApiUrl, + { entity_id: config.homeAssistant.automation }, + { + headers: { + Authorization: `Bearer ${config.homeAssistant.apiToken}`, + 'Content-Type': 'application/json', + }, + } + ); + logger.info('Successfully triggered Home Assistant automation.'); + } catch (error) { + logger.error('Error triggering Home Assistant automation:', { message: error.message }); + } +} + +module.exports = { triggerAutomation }; \ No newline at end of file diff --git a/src/pihole.js b/src/pihole.js new file mode 100644 index 0000000..59265da --- /dev/null +++ b/src/pihole.js @@ -0,0 +1,84 @@ +const axios = require('axios'); + +// This will store the session token +let apiToken = null; + +// The base URL for the v6 API +const getApiBaseUrl = (host) => `http://${host}/api`; + +async function login(config, logger) { + const loginUrl = `${getApiBaseUrl(config.pihole.host)}/auth`; + logger.info('No active session. Logging into Pi-hole v6...'); + try { + const response = await axios.post(loginUrl, { password: config.pihole.password }); + if (response.data && response.data.token) { + apiToken = response.data.token; + logger.info('Pi-hole v6 login successful. Session token obtained.'); + return true; + } + logger.error('Pi-hole v6 login failed. Response did not include a token.', { response: response.data }); + return false; + } catch (error) { + apiToken = null; + if (error.response && error.response.status === 401) { + logger.error('Pi-hole v6 login failed: Invalid password.', { status: error.response.status }); + } else { + logger.error('Error during Pi-hole v6 login:', { message: error.message }); + } + return false; + } +} + +async function getAllQueries(config, logger) { + const requiresAuth = !!config.pihole.password; + + // If no password is provided, do not attempt to log in. + if (!requiresAuth) { + const piholeApiUrl = `${getApiBaseUrl(config.pihole.host)}/queries`; + try { + const response = await axios.get(piholeApiUrl); + return { data: response.data.queries }; + } catch (error) { + logger.error('Error fetching data from password-less Pi-hole:', { message: error.message }); + return null; + } + } + + // Login if required and we don't have a token + if (!apiToken) { + const loggedIn = await login(config, logger); + if (!loggedIn) { + return null; // Stop if login fails + } + } + + const queriesUrl = `${getApiBaseUrl(config.pihole.host)}/queries`; + const options = { + headers: {}, + }; + + if (requiresAuth) { + options.headers['Authorization'] = apiToken; + } + + try { + const response = await axios.get(queriesUrl, options); + // The new API nests the data differently + return { data: response.data.queries }; + } catch (error) { + if (requiresAuth && error.response && error.response.status === 401) { + logger.warn('Pi-hole session token expired or invalid. Attempting to log in again...'); + apiToken = null; // Clear expired token + const loggedIn = await login(config, logger); + if (loggedIn) { + logger.info('Retrying to get all queries with new session...'); + return await getAllQueries(config, logger); // Retry the request once + } + return null; + } + logger.error('Error fetching data from Pi-hole v6 API:', { message: error.message }); + return null; + } +} + +module.exports = { getAllQueries }; \ No newline at end of file diff --git a/src/points.js b/src/points.js new file mode 100644 index 0000000..a438b16 --- /dev/null +++ b/src/points.js @@ -0,0 +1,54 @@ +const points = new Map(); + +function filterQueries(queries, filterStrings) { + const matchingDomains = new Set(); + if (!queries || !queries.data) { + return matchingDomains; + } + + for (const query of queries.data) { + const { domain, status } = query; + // Status 1: Blocked by gravity + // Status 4: Blocked by regex/wildcard + if (status === '1' || status === '4') { + for (const filterString of filterStrings) { + if (domain.toLowerCase().includes(filterString.toLowerCase())) { + matchingDomains.add(domain); + break; + } + } + } + } + return matchingDomains; +} + +function addPoint(domain, expirationSeconds, logger, config, discord) { + const now = Date.now(); + const expiration = now + expirationSeconds * 1000; + points.set(domain, expiration); + logger.info(`Point added for ${domain}. Total points: ${points.size}`); + if (config.notifications && config.notifications.discordWebhookUrl) { + discord.sendMessage(config.notifications.discordWebhookUrl, `Point added for ${domain}. Total points: ${points.size}`, logger); + } +} + +function getPointsCount() { + return points.size; +} + +function checkAndExpirePoints(logger) { + const now = Date.now(); + for (const [domain, expiration] of points.entries()) { + if (now > expiration) { + points.delete(domain); + logger.info(`Point expired for ${domain}. Total points: ${points.size}`); + } + } +} + +function clearPoints(logger) { + points.clear(); + logger.info('All points have been cleared.'); +} + +module.exports = { filterQueries, addPoint, getPointsCount, checkAndExpirePoints, clearPoints };