From 368bb3cd5b10849cfa328f6791eb028f574ca80d Mon Sep 17 00:00:00 2001 From: Spencer Grimes Date: Thu, 14 Aug 2025 15:40:52 -0500 Subject: [PATCH] Fully Functioning --- .gitignore | 1 + src/app.js | 24 ++++++--------- src/discord.js | 11 ++++++- src/pihole.js | 84 ++++++++++++++++++++++++-------------------------- src/points.js | 39 +++++++++-------------- 5 files changed, 75 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index 726e4ec..5e773dd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ SPEC.md package-lock.json combined.log error.log +debug.log diff --git a/src/app.js b/src/app.js index 62c64dd..7b4e595 100644 --- a/src/app.js +++ b/src/app.js @@ -1,7 +1,7 @@ const config = require('./config'); const winston = require('winston'); const { getAllQueries } = require('./pihole'); -const { filterQueries, addPoint, getPointsCount, checkAndExpirePoints, clearPoints } = require('./points'); +const { addPoint, getPointsCount, checkAndExpirePoints, clearPoints } = require('./points'); const { triggerAutomation } = require('./homeAssistant'); const discord = require('./discord'); @@ -14,7 +14,7 @@ const logger = winston.createLogger({ 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' }), + new winston.transports.File({ filename: 'combined.log' }) ], }); @@ -22,7 +22,6 @@ 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.'); @@ -35,7 +34,6 @@ function validateConfig(config) { 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); @@ -62,17 +60,13 @@ async function main() { checkAndExpirePoints(logger); - const queries = await getAllQueries(config, logger); + const queries = await getAllQueries(config, logger, config.filter.strings); - 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); + if (queries && queries.data) { + if (queries.data.length > 0) { + logger.info(`Found ${queries.data.length} matching domains.`); + for (const query of queries.data) { + addPoint(query.domain, config.pointExpirationSeconds, logger, config, discord); } } @@ -86,4 +80,4 @@ async function main() { }, config.pollingIntervalSeconds * 1000); } -main(); +main(); \ No newline at end of file diff --git a/src/discord.js b/src/discord.js index 3ab1c51..614d7cc 100644 --- a/src/discord.js +++ b/src/discord.js @@ -1,5 +1,14 @@ const axios = require('axios'); +/** + * Sends a message to a Discord webhook. + * @param {string} webhookUrl The Discord webhook URL. + * @param {string} message The message to send. + * @param {object} logger The Winston logger instance. + * @returns {Promise} + * @see https://discord.com/developers/docs/resources/webhook#execute-webhook + * @warning This function does not handle Discord's rate limiting. If you send too many messages in a short period, you may be rate limited (HTTP 429). + */ async function sendMessage(webhookUrl, message, logger) { if (!webhookUrl) { logger.warn('Discord webhook URL is not configured. Skipping message.'); @@ -14,4 +23,4 @@ async function sendMessage(webhookUrl, message, logger) { } } -module.exports = { sendMessage }; +module.exports = { sendMessage }; \ No newline at end of file diff --git a/src/pihole.js b/src/pihole.js index 59265da..b7ebdaf 100644 --- a/src/pihole.js +++ b/src/pihole.js @@ -29,56 +29,54 @@ async function login(config, logger) { } } -async function getAllQueries(config, logger) { +async function getAllQueries(config, logger, filterStrings) { 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) { + if (requiresAuth && !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; } + + const allMatchingQueries = []; + const piholeApiUrl = new URL(`${getApiBaseUrl(config.pihole.host)}/queries`); + + const now = Math.floor(Date.now() / 1000); + const from = now - (config.pollingIntervalSeconds || 10); + piholeApiUrl.searchParams.set('from', from); + piholeApiUrl.searchParams.set('until', now); + + for (const filter of filterStrings) { + piholeApiUrl.searchParams.set('domain', `*${filter}*`); + + const options = { + headers: {}, + }; + + if (requiresAuth) { + options.headers['Authorization'] = apiToken; + } + + try { + const response = await axios.get(piholeApiUrl.toString(), options); + if (response.data && response.data.queries) { + allMatchingQueries.push(...response.data.queries); + } + } catch (error) { + let errorMessage = `Error fetching queries for domain filter \"${filter}\".`; + if (requiresAuth && error.response && error.response.status === 401) { + errorMessage = `Pi-hole session token expired or invalid while fetching queries for \"${filter}\". A new token will be requested on the next polling cycle.`; + apiToken = null; // Clear expired token + } + logger.error(errorMessage, { message: error.message }); + } + } + + const uniqueQueries = Array.from(new Map(allMatchingQueries.map(q => [q.id, q])).values()); + + return { data: uniqueQueries }; } + module.exports = { getAllQueries }; \ No newline at end of file diff --git a/src/points.js b/src/points.js index a438b16..d32a295 100644 --- a/src/points.js +++ b/src/points.js @@ -1,34 +1,23 @@ 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; + + const pointExists = points.has(domain); points.set(domain, expiration); - logger.info(`Point added for ${domain}. Total points: ${points.size}`); + + if (pointExists) { + logger.info(`Point expiration updated for ${domain}. Total points: ${points.size}`); + } else { + 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); + const message = pointExists + ? `Point expiration updated for ${domain}. Total points: ${points.size}` + : `Point added for ${domain}. Total points: ${points.size}`; + discord.sendMessage(config.notifications.discordWebhookUrl, message, logger); } } @@ -51,4 +40,4 @@ function clearPoints(logger) { logger.info('All points have been cleared.'); } -module.exports = { filterQueries, addPoint, getPointsCount, checkAndExpirePoints, clearPoints }; +module.exports = { addPoint, getPointsCount, checkAndExpirePoints, clearPoints }; \ No newline at end of file