1
0

Neu: Pi-hole Client Name Manager

This commit is contained in:
Akamaru
2025-11-10 20:31:08 +01:00
parent 7ce3a2c84d
commit 9387fbc1cb
3 changed files with 736 additions and 0 deletions

View File

@@ -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 = `
<div id="pihole-client-manager-modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">
<i class="fa fa-edit"></i> Client Name Manager
</h4>
</div>
<div class="modal-body">
<div id="client-manager-loading" class="text-center" style="padding: 20px;">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<p>Lade Clients...</p>
</div>
<div id="client-manager-content" style="display: none;">
<div class="alert alert-info">
<i class="fa fa-info-circle"></i>
Gib für jeden Client einen Custom-Namen ein. Leere Felder verwenden den Original-Namen.
</div>
<div class="form-group">
<input type="text" id="client-filter" class="form-control" placeholder="Filter nach IP oder Name...">
</div>
<div style="max-height: 400px; overflow-y: auto;">
<table class="table table-striped table-bordered" id="client-manager-table">
<thead>
<tr>
<th style="width: 25%;">MAC/IP</th>
<th style="width: 35%;">Hostname</th>
<th style="width: 40%;">Custom-Name</th>
</tr>
</thead>
<tbody id="client-list">
</tbody>
</table>
</div>
</div>
<div id="client-manager-error" style="display: none;">
<div class="alert alert-danger">
<i class="fa fa-exclamation-triangle"></i>
<span id="error-message"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
<i class="fa fa-times"></i> Schließen
</button>
<button type="button" class="btn btn-warning" id="reset-names-btn">
<i class="fa fa-undo"></i> Alle zurücksetzen
</button>
<button type="button" class="btn btn-success" id="save-names-btn">
<i class="fa fa-save"></i> Speichern
</button>
</div>
</div>
</div>
</div>
`;
// 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, '&quot;');
// Zeige Kommentar in Tooltip falls vorhanden
const nameDisplay = safeComment ?
`<span title="${safeComment}">${safeName}</span>` :
safeName;
row.setAttribute('data-ip', safeIp);
row.setAttribute('data-name', safeName);
row.innerHTML = `
<td><code style="font-size: 11px;">${safeIp}</code></td>
<td>${nameDisplay}</td>
<td>
<input type="text"
class="form-control input-sm custom-name-input"
data-ip="${safeIp}"
value="${safeCustomName}"
placeholder="Custom-Name eingeben...">
</td>
`;
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 = '<i class="fa fa-check"></i> 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 = '<i class="fa fa-check"></i> 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 = `
<a href="#" id="open-client-manager">
<i class="fa-fw menu-icon fa-solid fa-address-card"></i>
<span>Client Names</span>
</a>
`;
// 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();
})();