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