Connection Succesful, working on filtering

This commit is contained in:
2025-08-14 14:56:56 -05:00
commit 72a2819aea
11 changed files with 403 additions and 0 deletions

11
.gitignore vendored Normal file
View File

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

56
README.md Normal file
View File

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

24
config.json.example Normal file
View File

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

16
package.json Normal file
View File

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

View File

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

89
src/app.js Normal file
View File

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

17
src/config.js Normal file
View File

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

17
src/discord.js Normal file
View File

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

22
src/homeAssistant.js Normal file
View File

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

84
src/pihole.js Normal file
View File

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

54
src/points.js Normal file
View File

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