From 9387fbc1cb342b3354ef03623c29cec654420845 Mon Sep 17 00:00:00 2001 From: Akamaru Date: Mon, 10 Nov 2025 20:31:08 +0100 Subject: [PATCH] Neu: Pi-hole Client Name Manager --- .gitignore | 1 + README.md | 23 + pihole-client-name-manager.user.js | 712 +++++++++++++++++++++++++++++ 3 files changed, 736 insertions(+) create mode 100644 .gitignore create mode 100644 pihole-client-name-manager.user.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c23d97c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.claude diff --git a/README.md b/README.md index 538411c..e7be22a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,29 @@ Blockiert nervige Popup-Werbung auf muchohentai.com, die sich bei jedem Klick ö --- +### 11. `pihole-client-name-manager.user.js` + +**Beschreibung:** +Ersetzt Client-Namen in den "Top Clients" Tabellen des Pi-hole Admin-Dashboards durch eigene Namen und bietet eine Management-Oberfläche zur Verwaltung. + +**Funktionen:** +- Automatische Ersetzung von Client-Namen in "Top Clients (total)" und "Top Clients (blocked only)" +- Management-Interface unter Settings → "Client Names" im Sidebar +- Vollständige Client-Liste mit MAC-Adressen, Hostnamen und Custom-Namen +- Filter-Funktion zum schnellen Finden von Clients +- Speichert Namen persistent mit Tampermonkey Storage (GM_setValue) +- Original-Namen werden als Tooltip beim Hover angezeigt +- Pi-hole API Integration (`/api/clients`) für vollständige Client-Liste +- Fallback auf Seiten-Scraping wenn API nicht verfügbar +- URL-Whitelist Verwaltung über Tampermonkey-Menü +- "URL zur Whitelist hinzufügen" - fügt aktuelle URL hinzu +- "Whitelist verwalten" - zeigt alle URLs und ermöglicht Löschen +- Automatische Erkennung von MAC → Hostname Zuordnung +- Kein Flackern durch intelligentes Update-System mit data-Attributen +- Update-Intervall: 500ms für schnelle Reaktion auf AJAX-Updates + +--- + ## Übersicht der enthaltenen UserStyles ### 1. `myanimelist-tweaks.user.css` diff --git a/pihole-client-name-manager.user.js b/pihole-client-name-manager.user.js new file mode 100644 index 0000000..7f878aa --- /dev/null +++ b/pihole-client-name-manager.user.js @@ -0,0 +1,712 @@ +// ==UserScript== +// @name Pi-hole Client Name Manager +// @namespace https://git.ponywave.de/Akamaru/Userscripts +// @version 1.0 +// @description Ersetzt Client-Namen in Pi-hole Admin Dashboard und bietet eine Management-Oberfläche +// @author Akamaru +// @match http://pi.hole/admin/* +// @match http://*/* +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_xmlhttpRequest +// @grant GM_registerMenuCommand +// @run-at document-idle +// @updateURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/pihole-client-name-manager.user.js +// @downloadURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/pihole-client-name-manager.user.js +// ==/UserScript== + +(function() { + 'use strict'; + + // Konfiguration + const CONFIG = { + defaultWhitelistedUrls: [ + 'http://pi.hole/admin/' + ], + storageKeys: { + clientNames: 'pihole_custom_client_names', + whitelistUrls: 'pihole_whitelist_urls' + }, + apiEndpoints: { + clients: '/api/clients' + } + }; + + // Lade Whitelist-URLs + function loadWhitelist() { + const stored = GM_getValue(CONFIG.storageKeys.whitelistUrls, '[]'); + try { + const parsed = JSON.parse(stored); + return [...CONFIG.defaultWhitelistedUrls, ...parsed]; + } catch (e) { + return [...CONFIG.defaultWhitelistedUrls]; + } + } + + // Speichere Whitelist-URLs (ohne Default-URLs) + function saveWhitelist(urls) { + // Filtere Default-URLs raus + const customUrls = urls.filter(url => !CONFIG.defaultWhitelistedUrls.includes(url)); + GM_setValue(CONFIG.storageKeys.whitelistUrls, JSON.stringify(customUrls)); + } + + // Prüfe ob aktuelle URL in Whitelist ist + function isWhitelisted() { + const currentOrigin = window.location.origin; + const currentPath = window.location.pathname; + const fullUrl = currentOrigin + currentPath; + + const whitelist = loadWhitelist(); + + // Prüfe ob eine URL aus der Whitelist matcht + return whitelist.some(url => { + // Entferne trailing /* falls vorhanden + const cleanUrl = url.replace(/\/\*$/, ''); + return fullUrl.startsWith(cleanUrl); + }); + } + + // Füge aktuelle URL zur Whitelist hinzu + function addCurrentUrlToWhitelist() { + const currentUrl = window.location.origin + window.location.pathname; + const whitelist = loadWhitelist(); + + if (!whitelist.includes(currentUrl)) { + whitelist.push(currentUrl); + saveWhitelist(whitelist); + alert(`URL zur Whitelist hinzugefügt:\n${currentUrl}\n\nBitte Seite neu laden.`); + } else { + alert('Diese URL ist bereits in der Whitelist.'); + } + } + + // Verwalte Whitelist über Dialog + function manageWhitelist() { + const whitelist = loadWhitelist(); + const customUrls = whitelist.filter(url => !CONFIG.defaultWhitelistedUrls.includes(url)); + + let message = 'Pi-hole Client Manager - Whitelist\n\n'; + message += 'Standard-URLs (nicht löschbar):\n'; + CONFIG.defaultWhitelistedUrls.forEach((url, i) => { + message += ` ${i + 1}. ${url}\n`; + }); + + if (customUrls.length > 0) { + message += '\nBenutzerdefinierte URLs:\n'; + customUrls.forEach((url, i) => { + message += ` ${i + 1}. ${url}\n`; + }); + message += '\n\nURL-Nummer eingeben zum Löschen, oder "0" zum Abbrechen:'; + + const input = prompt(message); + if (input && input !== '0') { + const index = parseInt(input) - 1; + if (index >= 0 && index < customUrls.length) { + customUrls.splice(index, 1); + saveWhitelist([...CONFIG.defaultWhitelistedUrls, ...customUrls]); + alert('URL entfernt! Bitte Seite neu laden.'); + } else { + alert('Ungültige Nummer.'); + } + } + } else { + message += '\nKeine benutzerdefinierten URLs vorhanden.\n\n'; + message += 'Verwende "URL zur Whitelist hinzufügen" im Tampermonkey-Menü.'; + alert(message); + } + } + + // Prüfe ob Seite Pi-hole Admin ist (Fallback) + function isPiHoleAdmin() { + return document.querySelector('.sidebar-menu') !== null && + document.querySelector('#client-frequency') !== null; + } + + // Speichere Custom-Namen + function saveCustomNames(names) { + GM_setValue(CONFIG.storageKeys.clientNames, JSON.stringify(names)); + } + + // Lade Custom-Namen + function loadCustomNames() { + try { + const data = GM_getValue(CONFIG.storageKeys.clientNames, '{}'); + return JSON.parse(data); + } catch (e) { + console.error('Fehler beim Laden der Custom-Namen:', e); + return {}; + } + } + + // Speichere Client-Mapping (MAC/IP -> Hostname Zuordnung) + let clientMapping = {}; + + function updateClientMapping(clients) { + // Erstelle Mapping: Hostname -> MAC/IP und IP -> MAC + clientMapping = {}; + clients.forEach(client => { + const identifier = client.ip; // MAC oder IP + const hostname = client.name; + + // Mapping: Hostname -> Identifier (mit und ohne .fritz.box) + if (hostname) { + const lowerHostname = hostname.toLowerCase(); + clientMapping[lowerHostname] = identifier; + + // Auch ohne Domain-Suffix speichern + const shortName = hostname.split('.')[0].toLowerCase(); + if (shortName !== lowerHostname) { + clientMapping[shortName] = identifier; + } + } + + // Wenn es eine IP-Adresse im Namen gibt, auch diese mappen + const ipMatch = hostname.match(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/); + if (ipMatch) { + clientMapping[ipMatch[0]] = identifier; + } + }); + } + + function findIdentifierForClient(hostnameOrIp) { + // Versuche direkt zu matchen + if (clientMapping[hostnameOrIp]) { + return clientMapping[hostnameOrIp]; + } + + // Versuche case-insensitive + const lower = hostnameOrIp.toLowerCase(); + if (clientMapping[lower]) { + return clientMapping[lower]; + } + + // Versuche ohne Domain-Suffix + const shortName = hostnameOrIp.split('.')[0].toLowerCase(); + if (clientMapping[shortName]) { + return clientMapping[shortName]; + } + + return hostnameOrIp; // Fallback + } + + // Hole Clients von API + function fetchClientsFromAPI() { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'GET', + url: window.location.origin + CONFIG.apiEndpoints.clients, + onload: function(response) { + try { + if (response.status === 200) { + const data = JSON.parse(response.responseText); + + // Prüfe verschiedene mögliche Datenstrukturen + let clientList = []; + if (data.clients && Array.isArray(data.clients)) { + clientList = data.clients; + } else if (Array.isArray(data)) { + clientList = data; + } else if (data.data && Array.isArray(data.data)) { + clientList = data.data; + } + + // Normalisiere Client-Objekte + clientList = clientList.map(client => { + // Pi-hole API v6 nutzt 'client' für MAC/IP, 'name' für Hostname + const identifier = client.ip || client.client || client.address || client.id; + const displayName = client.name || client.hostname || client.comment || identifier || 'Unknown'; + + return { + ip: String(identifier || 'unknown'), + name: String(displayName), + comment: client.comment || '' + }; + }).filter(client => client.ip !== 'unknown'); + + if (clientList.length > 0) { + resolve(clientList); + } else { + resolve(getClientsFromPage()); + } + } else if (response.status === 401) { + resolve(getClientsFromPage()); + } else { + resolve(getClientsFromPage()); + } + } catch (e) { + console.error('Pi-hole Client Manager: Fehler beim Parsen der API-Antwort:', e); + resolve(getClientsFromPage()); + } + }, + onerror: function(error) { + console.error('Pi-hole Client Manager: API Request fehlgeschlagen:', error); + resolve(getClientsFromPage()); + } + }); + }); + } + + // Fallback: Hole Clients von der Seite + function getClientsFromPage() { + const clients = []; + const tables = [ + document.querySelector('#client-frequency table'), + document.querySelector('#client-frequency-blocked table') + ]; + + tables.forEach(table => { + if (!table) return; + const rows = table.querySelectorAll('tbody tr'); + rows.forEach(row => { + const link = row.querySelector('td:first-child a'); + if (link) { + const href = link.getAttribute('href'); + const ipMatch = href.match(/client_ip=([^&]+)/); + if (ipMatch) { + const ip = ipMatch[1]; + const name = link.textContent.trim(); + // Verhindere Duplikate + if (!clients.find(c => c.ip === ip)) { + clients.push({ + ip: ip, + name: name + }); + } + } + } + }); + }); + + return clients; + } + + // Ersetze Client-Namen in Tabellen + function replaceClientNames() { + const customNames = loadCustomNames(); + + const tables = [ + document.querySelector('#client-frequency table'), + document.querySelector('#client-frequency-blocked table') + ]; + + let replacedCount = 0; + + tables.forEach(table => { + if (!table) return; + const rows = table.querySelectorAll('tbody tr'); + rows.forEach(row => { + const link = row.querySelector('td:first-child a'); + if (link) { + const href = link.getAttribute('href'); + const ipMatch = href.match(/client_ip=([^&]+)/); + if (ipMatch) { + const ip = ipMatch[1]; + + // Prüfe ob Link bereits bearbeitet wurde + let originalName; + if (link.hasAttribute('data-original-name')) { + // Link wurde bereits ersetzt, hole Original-Namen aus data-Attribut + originalName = link.getAttribute('data-original-name'); + } else { + // Erster Durchlauf, hole Original-Namen aus Text + originalName = link.textContent.trim(); + // Speichere Original-Namen für spätere Durchläufe + link.setAttribute('data-original-name', originalName); + } + + // Versuche den Identifier (MAC) für diese IP/Hostname zu finden + const identifierFromIp = findIdentifierForClient(ip); + const identifierFromName = findIdentifierForClient(originalName); + + // Priorität: MAC-Adresse (enthält :) über IP-Adresse + let identifier; + if (identifierFromName && identifierFromName.includes(':')) { + identifier = identifierFromName; // MAC-Adresse vom Hostname + } else if (identifierFromIp && identifierFromIp.includes(':')) { + identifier = identifierFromIp; // MAC-Adresse von IP + } else { + identifier = identifierFromName || identifierFromIp; // Fallback + } + + // Prüfe ob ein Custom-Name existiert (mit IP, Hostname oder MAC) + let customName = customNames[identifier] || customNames[originalName] || customNames[ip]; + + if (customName) { + // Setze Custom-Namen nur wenn er sich unterscheidet + if (link.textContent !== customName) { + link.textContent = customName; + replacedCount++; + } + // Füge Tooltip mit Original-Namen hinzu + link.title = 'Original: ' + originalName + '\nIP: ' + ip; + link.style.cursor = 'help'; + } else { + // Kein Custom-Name mehr vorhanden, stelle Original wieder her + if (link.textContent !== originalName) { + link.textContent = originalName; + } + // Entferne Tooltip + link.removeAttribute('title'); + link.style.cursor = ''; + } + } + } + }); + }); + } + + // Erstelle Management-Modal + function createManagementModal() { + // Modal HTML + const modalHtml = ` + + `; + + // Füge Modal zum Body hinzu + const modalContainer = document.createElement('div'); + modalContainer.innerHTML = modalHtml; + document.body.appendChild(modalContainer); + + // Event Listeners + document.getElementById('save-names-btn').addEventListener('click', saveNamesFromModal); + document.getElementById('reset-names-btn').addEventListener('click', resetAllNames); + document.getElementById('client-filter').addEventListener('input', filterClientTable); + + // Lade Clients wenn Modal geöffnet wird + $('#pihole-client-manager-modal').on('show.bs.modal', function() { + loadClientsIntoModal(); + }); + } + + // Lade Clients in Modal + async function loadClientsIntoModal() { + const loadingDiv = document.getElementById('client-manager-loading'); + const contentDiv = document.getElementById('client-manager-content'); + const errorDiv = document.getElementById('client-manager-error'); + + loadingDiv.style.display = 'block'; + contentDiv.style.display = 'none'; + errorDiv.style.display = 'none'; + + try { + const clients = await fetchClientsFromAPI(); + const customNames = loadCustomNames(); + const tbody = document.getElementById('client-list'); + tbody.innerHTML = ''; + + if (clients.length === 0) { + throw new Error('Keine Clients gefunden'); + } + + // Aktualisiere Client-Mapping für Namen-Ersetzung + updateClientMapping(clients); + + // Sortiere nach Name (alphabetisch) + clients.sort((a, b) => { + // Sortiere nach Name, dann nach IP/MAC + const nameA = (a.name || '').toLowerCase(); + const nameB = (b.name || '').toLowerCase(); + + if (nameA !== nameB) { + return nameA.localeCompare(nameB); + } + + // Falls Namen gleich, sortiere nach IP/MAC + const ipA = String(a.ip || ''); + const ipB = String(b.ip || ''); + return ipA.localeCompare(ipB); + }); + + clients.forEach(client => { + // Sicherheitsprüfung + if (!client.ip) return; + + const row = document.createElement('tr'); + const safeIp = String(client.ip || '').replace(/[<>&"']/g, ''); + const safeName = String(client.name || 'Unknown').replace(/[<>&"']/g, ''); + const safeComment = String(client.comment || '').replace(/[<>&"']/g, ''); + const customName = customNames[client.ip] || ''; + const safeCustomName = String(customName).replace(/"/g, '"'); + + // Zeige Kommentar in Tooltip falls vorhanden + const nameDisplay = safeComment ? + `${safeName}` : + safeName; + + row.setAttribute('data-ip', safeIp); + row.setAttribute('data-name', safeName); + + row.innerHTML = ` + ${safeIp} + ${nameDisplay} + + + + `; + tbody.appendChild(row); + }); + + loadingDiv.style.display = 'none'; + contentDiv.style.display = 'block'; + } catch (error) { + console.error('Fehler beim Laden der Clients:', error); + document.getElementById('error-message').textContent = error.message; + loadingDiv.style.display = 'none'; + errorDiv.style.display = 'block'; + } + } + + // Filter Client-Tabelle + function filterClientTable() { + const filter = document.getElementById('client-filter').value.toLowerCase(); + const rows = document.querySelectorAll('#client-list tr'); + + rows.forEach(row => { + const ip = row.getAttribute('data-ip').toLowerCase(); + const name = row.getAttribute('data-name').toLowerCase(); + const customInput = row.querySelector('.custom-name-input'); + const customName = customInput ? customInput.value.toLowerCase() : ''; + + if (ip.includes(filter) || name.includes(filter) || customName.includes(filter)) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + }); + } + + // Speichere Namen aus Modal + function saveNamesFromModal() { + const inputs = document.querySelectorAll('.custom-name-input'); + const customNames = loadCustomNames(); + + inputs.forEach(input => { + const ip = input.getAttribute('data-ip'); + const value = input.value.trim(); + + if (value) { + customNames[ip] = value; + } else { + delete customNames[ip]; + } + }); + + saveCustomNames(customNames); + replaceClientNames(); + + // Zeige Erfolgs-Nachricht + const saveBtn = document.getElementById('save-names-btn'); + const originalHtml = saveBtn.innerHTML; + saveBtn.innerHTML = ' Gespeichert!'; + saveBtn.disabled = true; + + setTimeout(() => { + saveBtn.innerHTML = originalHtml; + saveBtn.disabled = false; + }, 2000); + } + + // Setze alle Namen zurück + function resetAllNames() { + if (confirm('Möchtest du wirklich alle Custom-Namen löschen?')) { + saveCustomNames({}); + replaceClientNames(); + + // Leere alle Inputs + const inputs = document.querySelectorAll('.custom-name-input'); + inputs.forEach(input => input.value = ''); + + // Zeige Bestätigung + const resetBtn = document.getElementById('reset-names-btn'); + const originalHtml = resetBtn.innerHTML; + resetBtn.innerHTML = ' Zurückgesetzt!'; + resetBtn.disabled = true; + + setTimeout(() => { + resetBtn.innerHTML = originalHtml; + resetBtn.disabled = false; + }, 2000); + } + } + + // Füge Menüpunkt zum Sidebar hinzu + function addMenuEntry() { + const toolsMenu = document.querySelector('.menu-system.treeview ul.treeview-menu'); + if (!toolsMenu) { + return; + } + + // Prüfe ob Eintrag bereits existiert + if (document.querySelector('#client-manager-menu-item')) { + return; + } + + const menuItem = document.createElement('li'); + menuItem.id = 'client-manager-menu-item'; + menuItem.innerHTML = ` + + + Client Names + + `; + + // Füge nach "Network" Eintrag ein + const networkItem = Array.from(toolsMenu.querySelectorAll('li')).find(li => + li.textContent.includes('Network') + ); + + if (networkItem) { + networkItem.after(menuItem); + } else { + toolsMenu.appendChild(menuItem); + } + + // Event Listener für Menüpunkt + document.getElementById('open-client-manager').addEventListener('click', function(e) { + e.preventDefault(); + $('#pihole-client-manager-modal').modal('show'); + }); + } + + // Beobachte Änderungen an Tabellen + function observeTableChanges() { + const targetNode = document.querySelector('body'); + if (!targetNode) return; + + const config = { childList: true, subtree: true }; + + const callback = function(mutationsList, observer) { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + // Prüfe ob Tabellen aktualisiert wurden + const hasClientTable = mutation.target.querySelector('#client-frequency table') || + mutation.target.querySelector('#client-frequency-blocked table'); + if (hasClientTable) { + replaceClientNames(); + } + } + } + }; + + const observer = new MutationObserver(callback); + observer.observe(targetNode, config); + } + + // Registriere Tampermonkey-Menübefehle + GM_registerMenuCommand('URL zur Whitelist hinzufügen', addCurrentUrlToWhitelist); + GM_registerMenuCommand('Whitelist verwalten', manageWhitelist); + + // Initialisierung + async function init() { + // Prüfe Whitelist - Script läuft nur auf whitelisteten URLs + if (!isWhitelisted()) { + return; + } + + // Prüfe ob es eine Pi-hole Admin-Seite ist + if (!isPiHoleAdmin()) { + return; + } + + // Warte bis Seite vollständig geladen ist + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + return; + } + + // Warte auf jQuery und Bootstrap + if (typeof jQuery === 'undefined' || typeof jQuery.fn.modal === 'undefined') { + setTimeout(init, 100); + return; + } + + // Lade Client-Liste und erstelle Mapping + try { + const clients = await fetchClientsFromAPI(); + updateClientMapping(clients); + } catch (e) { + console.error('Pi-hole Client Manager: Konnte Client-Mapping nicht initialisieren:', e); + } + + // Ersetze Namen in Tabellen + replaceClientNames(); + + // Füge Modal hinzu + createManagementModal(); + + // Füge Menüpunkt hinzu + addMenuEntry(); + + // Beobachte Änderungen + observeTableChanges(); + + // Aktualisiere Namen periodisch (falls Tabellen neu geladen werden) + // Kurzes Intervall für schnelle Reaktion auf AJAX-Updates + setInterval(replaceClientNames, 500); + } + + // Starte Script + init(); +})();