Connection Succesful, working on filtering
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
56
README.md
Normal 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
24
config.json.example
Normal 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
16
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
pihole-ha-connector.service.example
Normal file
13
pihole-ha-connector.service.example
Normal 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
89
src/app.js
Normal 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
17
src/config.js
Normal 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
17
src/discord.js
Normal 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
22
src/homeAssistant.js
Normal 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
84
src/pihole.js
Normal 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
54
src/points.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user