// ==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(); })();