ctaas Netzwerk-Monitor/Netzwerkscanner:
© ctaas.de ♡ V2026-06-04 ❀
Diese Dokumentation beschreibt die Einrichtung und Funktionsweise eines ressourcenschonenden live Netzwerkmonitors zur Überwachung des Online-Status von lokalen Geräten.
Das System eignet sich für den Betrieb unter Linux, sofern ein Webserver, PHP und Python 3 zur Verfügung stehen.
Diese Anleitung basiert auf einer minimierten Installation von Ubuntu Server (Version 24.04 LTS, 26.04 LTS und neuer).
Das Gesamtsystem besteht aus folgenden Komponenten:
- Einem im Hintergrund laufenden Python-Backend (Scanner).
- Einer zentralen Konfigurationsdatei im CSV-Format, die über einen grafischen PHP-Editor gepflegt werden kann.
- Einem HTML-Frontend, das die Scan-Ergebnisse per AJAX (Fetch API) alle 10 Sekunden live und ohne vollständigen Seiten-Reload visualisiert.
Das System trennt Daten und Layout strikt voneinander. Nur das Hauptskript im Hintergrund benötigt Python.
1. Einrichtung des Webservers (lighttpd)
Zur Darstellung der Weboberfläche im lokalen Netzwerk wird ein Webserver benötigt dieser sollte möglichst zuerst eingerichtet werden weil die nachfolgenden Dateien im Webspace abgelegt werden.
Es wird die Verwendung von lighttpd empfohlen. Dieser ist im Vergleich zu Apache extrem leichtgewichtig, benötigt weniger Abhängigkeiten,
schont die Systemressourcen und reicht für diesen Einsatzzweck vollständig aus.
1.1 Installation von lighttpd
Führen Sie zur Installation des Webservers folgenden Befehl aus:
apt update
apt install lighttpd
1.2 Konfiguration anpassen
Die Konfigurationsdatei des Webservers entsprechend angepasst werden.
Nutzen Sie dazu je nach Umgebung den passenden Texteditor:
nano /etc/lighttpd/lighttpd.conf (im Terminal auf Systemen ohne grafische Benutzeroberfläche)
pluma /etc/lighttpd/lighttpd.conf (auf Systemen mit installierter grafischer Desktop-Umgebung)
Passen Sie die Konfiguration im Bereich des Dokumenten-Wurzelverzeichnisses (Document-Root) wie folgt an:
...
server.modules = (
"mod_indexfile",
"mod_access",
"mod_alias",
"mod_redirect",
)
server.document-root = "/var/www/html/netzwerk-monitor.htm"
server.upload-dirs = ( "/var/cache/lighttpd/uploads" )
server.errorlog = "/var/log/lighttpd/error.log"
server.pid-file = "/var/run/lighttpd.pid"
server.username = "www-data"
server.groupname = "www-data"
server.port = 80
...
1.3 Aktivierung und Firewall-Freigabe
Nachdem die Konfiguration gespeichert wurde, muss der Dienst neu gestartet werden, um die Änderungen zu übernehmen.
Zudem muss der HTTP-Port 80 in der Firewall freigegeben werden, damit andere Computer im Netzwerk auf die Seite zugreifen können:
systemctl restart lighttpd
ufw allow 80/tcp
2. Das Python-Hauptskript (Scanner)
Damit das Python-Skript auf einer minimierten Ubuntu-Server-Installation fehlerfrei ausgeführt werden kann,
müssen die Paketquellen aktualisiert und die Laufzeitumgebung sowie das Ping-Werkzeug installiert werden.
Führen Sie dazu folgende Befehle im Terminal aus:
apt update
apt install python3 iputils-ping
Das folgende Skript arbeitet wie folgt:
Es pingt in einem festen Intervall von 10 Sekunden alle IP-Adressen an, die in der Konfigurationsdatei hinterlegt sind.
Antwortet ein Gerät (z. B. ein PC, Drucker, Smartphone ...) auf den Ping-Befehl, wird es im System als "Online" markiert.
Durch den erfolgreichen Netzwerkverkehr wird gleichzeitig der lokale ARP-Cache des Betriebssystems mit den aktuellen MAC-Adressen der aktiven Geräte befüllt.
Das Skript liest diese MAC-Adressen aus und führt sie mit den Gerätedaten zusammen.
Die gesammelten Ergebnisse werden anschließend in der Datei 'netzwerk-scan.json' atomar sicher gespeichert.
Legt das Scan-Skript am einfachsten erstmal direkt im Webspace-Order ab.
Wenn Sie das Skript wo anders speichern müssen die Pfade im Skript entsprechend angepasst werden.
Für Test erste Installation empfehle ich die Ablage direkt im Webspace /var/www/html-Ordner.
cd /var/www/html
nano netzwerk-monitor.py
pluma netzwerk-monitor.py
Hier dann den folgenden Inhalt in der 'netzwerk-monitor.py' einfügen:
#!/usr/bin/env python3
import csv
import subprocess
import os
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from zoneinfo import ZoneInfo
CSV_PATH = '/var/www/html/netzwerk-hosts.csv'
JSON_PATH = '/var/www/html/netzwerk-scan.json'
MAX_WORKERS = 15
def read_hosts(csv_path):
ip_info = {}
mac_info = {}
with open(csv_path, newline='', encoding='utf-8-sig') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
ip = row.get('ip', '').strip()
mac = row.get('mac', '').strip().upper().replace('-', ':')
name = row.get('pc_name', '').strip()
user = row.get('username', '').strip()
# IPs (inklusive der DHCP-Platzhalter) in die IP-Liste
if ip:
ip_info[ip] = {'pc_name': name, 'username': user}
# Reine MAC-Einträge (z.B. Handys) in die MAC-Liste
elif mac:
mac_info[mac] = {'pc_name': name, 'username': user}
return ip_info, mac_info
def ping_host(ip):
try:
result = subprocess.run(
['ping', '-c', '1', '-W', '1', ip],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
return ip, (result.returncode == 0)
except Exception:
return ip, False
def get_arp_table():
arp_cache = {}
try:
with open('/proc/net/arp', 'r') as f:
next(f) # Überspringe Kopfzeile
for line in f:
parts = line.split()
if len(parts) >= 4:
ip = parts[0]
mac = parts[3].upper().replace('-', ':')
if mac != '00:00:00:00:00:00':
arp_cache[ip] = mac
except Exception:
pass
return arp_cache
def ip_to_tuple(ip):
try:
return tuple(int(part) for part in ip.split('.'))
except:
return (0,0,0,0)
def main():
if not os.path.isfile(CSV_PATH):
print(f"Fehler: Datei {CSV_PATH} nicht gefunden.")
return
ip_info, mac_info = read_hosts(CSV_PATH)
all_ips_to_ping = list(ip_info.keys())
ping_results = {}
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(ping_host, ip): ip for ip in all_ips_to_ping}
for future in as_completed(futures):
ip, is_online = future.result()
ping_results[ip] = is_online
# ARP Cache auslesen, der durch die Pings frisch befüllt wurde
arp_cache = get_arp_table()
output_data = []
online_count = 0
total_count = len(ping_results)
for ip, is_online in ping_results.items():
status = 'Online' if is_online else 'Offline'
if is_online:
online_count += 1
current_mac = arp_cache.get(ip, '')
# Standardwerte aus der CSV übernehmen (IP-basiert)
pc_name = ip_info[ip]['pc_name']
username = ip_info[ip]['username']
# Wenn das Gerät online ist und die MAC in unseren bekannten MACs auftaucht: Überschreiben
if current_mac and current_mac in mac_info:
pc_name = mac_info[current_mac]['pc_name']
username = mac_info[current_mac]['username']
output_data.append({
'pc_name': pc_name,
'username': username,
'ip': ip,
'mac': current_mac,
'status': status
})
# Sortiere nach IP
output_data.sort(key=lambda x: ip_to_tuple(x['ip']))
current_time = datetime.now(ZoneInfo("Europe/Berlin"))
update_time_str = current_time.strftime('%Y-%m-%d / %H:%M:%S Uhr')
json_payload = {
'update_time': update_time_str,
'online_count': online_count,
'total_count': total_count,
'devices': output_data
}
# Atomares Schreiben (verhindert Race Conditions mit dem Webserver)
tmp_json_path = JSON_PATH + '.tmp'
with open(tmp_json_path, 'w', encoding='utf-8') as f:
json.dump(json_payload, f, ensure_ascii=False, indent=2)
# Datei per 'mv' austauschen
os.replace(tmp_json_path, JSON_PATH)
print(f"JSON-Datei '{JSON_PATH}' wurde atomar aktualisiert.")
if __name__ == '__main__':
main()
2.1 Das Skript ausführbar machen und manuell starten
Rechte zum ausführen hinzufügen:
chmod +x /var/www/html/netzwerk-monitor.py
2.2 Automatischer Start und Endlosschleife
Um das Skript dauerhaft in einer Schleife laufen zu lassen, gibt es zwei Möglichkeiten.
Variante A:
Start über ein einfaches Bash-Skript (schnelle Variante)
Sie können ein separates Steuerungsskript z. B. '_starte-netzwerk-monitor.sh' im gleichen Verzeichnis anlegen:
nano /var/www/html/_starte-netzwerk-monitor.sh
pluma /var/www/html/_starte-netzwerk-monitor.sh
Fügen Sie dort folgenden Inhalt ein:
#!/bin/bash
echo "Netzwerk-Monitor gestartet. Beenden mit Strg+C"
while true; do
python3 /var/www/html/netzwerk-monitor.py
sleep 10
done
Skript ausfürhbar kennzeichnen und starten:
chmod +x /var/www/html/_starte-netzwerk-monitor.sh
./var/www/html/_starte-netzwerk-monitor.sh
Beachten Sie, dass diese Variante abbricht, sobald die SSH-Sitzung oder das Terminalfenster geschlossen wird.
Variante B:
Einrichtung als systemd-Service (Empfohlene Profilösung)
Für einen dauerhaften und ausfallsicheren Betrieb im Hintergrund sollte das Skript als Systemdienst (Service) eingerichtet werden.
Der Monitor startet dann nach einem Server-Neustart automatisch und bei eventuellen Fehlern selbstständig neu.
Erstellen Sie dazu eine neue Dienst-Konfigurationsdatei:
nano /etc/systemd/system/netzwerk-monitor.service
pluma /etc/systemd/system/netzwerk-monitor.service
Fügen Sie folgenden Inhalt ein:
[Unit]
Description=Netzwerk Monitor Python Skript
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/www/html
ExecStart=/usr/bin/python3 /var/www/html/netzwerk-monitor.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
Aktivieren und starten Sie den Dienst anschließend mit den Befehlen:
systemctl enable netzwerk-monitor.service
systemctl start netzwerk-monitor.service
Der Dienst steuert den 10-Sekunden-Abstand nun automatisch über die Anweisung "RestartSec=10", sobald sich das Python-Skript nach einem Durchlauf beendet.
3. Die Konfigurationsdatei (netzwerk-hosts.csv)
Das Python-Skript liest alle zu überprüfenden Geräte aus der Datei "netzwerk-hosts.csv".
Diese Datei muss im selben Verzeichnis liegen (ggf. Pfade anpassen).
Der später folgende Editor würde zwar die Datei auch entsprechend korrekt anlegen,
es empfieht sich aber für Test eine kleine Testdatei selbst direkt zu erstellen:
nano /var/www/html/netzwerk-hosts.csv
pluma /var/www/html/netzwerk-hosts.csv
Hier dann folgendes in der 'netzwerk-hosts.csv' einfügen:
ip,pc_name,username,mac
192.168.0.100,"TEST-Server","Server-USER",
192.168.0.100,"TEST-PC","TEST-USER",
Die IP-Adresse sollte auf einen lokalen erreichbaren PC/Netzwerkgerät verweisen der auf ping antwortet.
Die Datei sollte im UTF-8-Format (ohne BOM) mit Unix-Zeilenumbrüchen (LF) gespeichert werden (wird in der Regel automatisch korrekt erzeugt wenn man sie unter Linux hier so anlegt).
Zur Erklärung nochmal etwas genauer:
Die Datei ist wie folgt aufgebaut:
ip,pc_name,username,mac = 4 Spalten (IP-Adresse, PC-Name, User, MAC-Adresse)
,"Google Pixel","Handy Admin (Arno)",C1:A2:F3:F4:E5:E6 = Beachtet hier: Es wurde hier keine IP-Adresse, dafür die MAC Adresse angegeben (dies ist ein Gerät ohne feste IP-Adresse).
192.168.0.11,"Lenovo","PC Empfang/Herr Muster", = ein normaler PC, die MAC-Adresse wird hier ausdrücklich nicht angegeben diese wird automatisch ermittelt
192.168.0.12,"Dell","PC Büro1/Frau Muster", = auch ein normaler PC ohne angabe der MAC-Adresse die Spalte 4 nach dem Komma bleibt leer
192.168.0.40,"OKI","Drucker", = andere Netzwerkgeräte wie Drucker, Smartphones usw. werden alle identisch ohne MAC-Adresse angegeben
192.168.0.100,"DHCP","DHCP", = Der DHCP-Bereich muss komplett angegeben werden, das scheint am Anfang aufwändig.
192.168.0.101,"DHCP","DHCP",
192.168.0.102,"DHCP","DHCP", = Es erleichtert jedoch schnelle individuelle Anpassungen wie z. B. direkte Ausnahmen bei einzelnen festen IPs im DHCP-Bereich.
192.168.0.103,"Test-Server","Test-System", = Hier z. B.ein Testsystem im DHCP-Bereich mit fester IP, man kann hier den Namen direkt schnell vergeben.
...
192.168.0.108,"DHCP","DHCP",
192.168.0.109,"DHCP","DHCP",
192.168.0.110,"DHCP","DHCP",
Für die korrekte Zuordnung gelten also folgende verbindliche Regeln:
- Mobile Endgeräte oder Computer, die ihre IP-Adresse dynamisch über den DHCP-Server beziehen (z. B. das Google Pixel des Admins),
werden ohne IP-Adresse eingetragen (das Feld 'ip' bleibt komplett leer). Stattdessen wird in der vierten Spalte die feste MAC-Adresse des Gerätes hinterlegt.
Erkennt das Netzwerk-Monitor-Skript bei den regelmäßigen Pings auf den DHCP-Pool eine live ermittelte MAC-Adresse, die mit diesem reinen MAC-Eintrag übereinstimmt,
dann wird der Platzhalter "DHCP" in der Ausgabe automatisch durch die hier definierten Klarnamen (z. B. Google Pixel / Handy Admin) ersetzt.
- Geräte mit einer festen, statischen IP-Adresse (z. B. der Lenovo PC) werden mit ihrer IP-Adresse, dem eindeutigen Gerätenamen sowie dem Nutzer/Standort erfasst.
Bei diesen statischen Geräten muss keine MAC-Adresse in der CSV-Datei hinterlegt werden. Das Feld bleibt leer, da das Skript die MAC-Adresse live im Netzwerk abfragt.
Die Angabe der MAC-Adresse wäre auch nicht zielführend, da sich MAC-Adressen durchaus auch während der Laufzeit ändern können.
Daher ist die Erkennung der MAC-Adressen über das Skript Sinnvoller.
- IP-Adressen aus dem dynamischen DHCP-Bereich des Routers müssen alle einzeln, als eigene Zeile, in der CSV-Datei eingetragen werden (im Beispiel der Bereich von .100 bis .110).
Als Platzhalter für den PC-Namen und den Benutzernamen kann ein beliebiger Text hinterlegt werden ("DHCP" wird empfohlen).
3.1 Dateirechte festlegen
Um die Datei später mit dem Editor zu bearbeiten sind Schreibrechte für den Webserver auf der CSV-Datei notwendig:
chmod 666 /var/www/html/netzwerk-hosts.csv
4. Der grafische Web-Editor 'netzwerk-hosts-editor.php'
Die Verwaltung der einzelnen Netzwerkgeräte der CSV-Datei kann komfortabel über den Web-Editor 'netzwerk-hosts-editor.php' erfolgen.
Dieser bietet eine grafische Oberfläche im Browser, unterstützt eine Live-Suche sowie Sortierfunktionen und
erlaubt das Verschieben, Löschen oder Hinzufügen von Zeilen.
Beim Speichern übermittelt das Formular stets den gesamten Datenbestand, unabhängig von Filtereinstellungen.
Im Editor sind weiterhin einige Überprüfungen eingebaut die unter anderem die IPv4-Adressen überprüfen
oder die Angabe einer IP-Adresse und MAC-Adresse gleichzeitig verhindern (es ist nur eins notwendig).
Damit der lighttpd-Webserver PHP-Skripte verarbeiten kann, muss man nun noch die entsprechenden PHP-Komponenten installieren und im Webserver aktivieren:
apt update
apt install php-cgi php-fpm
lighty-enable-mod fastcgi fastcgi-php
systemctl restart lighttpd
Nun kann man den Web-Editor am einfachsten so erstellen:
nano /var/www/html/netzwerk-hosts-editor.php
Pluma /var/www/html/netzwerk-hosts-editor.php
Hier nun den folgenden Code vom 'netzwerk-hosts-editor.php' einfügen:
<?php
header("Cache-Control: no-cache, no-store, must-revalidate"); // HTTP 1.1
header("Pragma: no-cache"); // HTTP 1.0
header("Expires: 0"); // Proxies
$csv_file = __DIR__ . '/netzwerk-hosts.csv';
$message = '';
$error = '';
$hosts = [];
if (file_exists($csv_file)) {
if (($handle = fopen($csv_file, 'r')) !== false) {
while (($row = fgetcsv($handle, 1000, ',')) !== false) {
$hosts[] = $row;
}
fclose($handle);
// BOM aus der allerersten Zelle entfernen, falls vorhanden
if (!empty($hosts) && isset($hosts[0][0])) {
$hosts[0][0] = preg_replace('/^\xEF\xBB\xBF/', '', $hosts[0][0]);
}
}
}
// Fallback für leere Datei
if (empty($hosts)) {
$hosts[] = ['ip', 'pc_name', 'username', 'mac'];
}
$header = $hosts[0];
$data_rows = array_slice($hosts, 1);
function save_csv($header, $rows, $csv_file) {
if (($fp = fopen($csv_file, 'w')) !== false) {
fputcsv($fp, $header, ',');
foreach ($rows as $row) {
fputcsv($fp, $row, ',');
}
fclose($fp);
return true;
}
return false;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data_rows = [];
if (isset($_POST['rows'])) {
foreach ($_POST['rows'] as $row) {
// Zeile wird nur ignoriert, wenn wirklich alle 4 Felder leer sind
if (array_filter($row)) {
$data_rows[] = [
trim($row[0] ?? ''),
trim($row[1] ?? ''),
trim($row[2] ?? ''),
trim($row[3] ?? '')
];
}
}
}
if (isset($_POST['delete']) && isset($_POST['index'])) {
unset($data_rows[intval($_POST['index'])]);
$data_rows = array_values($data_rows);
save_csv($header, $data_rows, $csv_file);
$message = "Eintrag gelöscht.";
}
if (isset($_POST['move']) && isset($_POST['index']) && isset($_POST['direction'])) {
$index = intval($_POST['index']);
$direction = $_POST['direction'];
if ($direction === 'up' && $index > 0) {
[$data_rows[$index - 1], $data_rows[$index]] = [$data_rows[$index], $data_rows[$index - 1]];
} elseif ($direction === 'down' && $index < count($data_rows) - 1) {
[$data_rows[$index], $data_rows[$index + 1]] = [$data_rows[$index + 1], $data_rows[$index]];
}
save_csv($header, $data_rows, $csv_file);
$message = "Reihenfolge geändert.";
}
if (isset($_POST['add_row'])) {
$data_rows[] = ["", "", "", ""];
save_csv($header, $data_rows, $csv_file);
$message = "Neue Zeile hinzugefügt.";
}
if (isset($_POST['save'])) {
if (count($header) < 4) { $header[3] = 'mac'; }
if (save_csv($header, $data_rows, $csv_file)) {
$message = "Gespeichert.";
} else {
$error = "Fehler beim Schreiben der Datei.";
}
}
}
?>
<!DOCTYPE html><html lang="de"><head>
<meta charset="UTF-8">
<link rel="shortcut icon" type="image/x-icon" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor-edit.ico">
<link rel="icon" type="image/x-icon" sizes="16x16 24x24 32x32 48x48 64x64 96x96 128x128 256x256" href="https://ctaas.de/box/netzwerk-monitor-edit.ico">
<link rel="icon" type="image/png" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor-edit.png">
<link rel="apple-touch-icon" href="https://ctaas.de/box/netzwerk-monitor-edit.png">
<title>Hosts bearbeiten</title>
<style>
body { font-family: Arial, sans-serif; margin: 30px; background-color: #f5f5f5; }
.msg { color: green; font-weight: bold; margin-bottom: 15px; }
.error { color: red; font-weight: bold; margin-bottom: 15px; }
.hint-box { background-color: #eef; border-left: 4px solid #0066cc; padding: 10px 15px; color: #555; font-size: 0.9em; margin-bottom: 20px; }
.search-container { position: relative; display: inline-block; width: 100%; max-width: 400px; margin-bottom: 15px; }
.search-box { width: 100%; padding: 8px 30px 8px 10px; box-sizing: border-box; border: 1px solid #ccc; }
#clear-search-btn { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); cursor: pointer; color: #999; font-weight: bold; display: none; }
table { border-collapse: collapse; width: 100%; background-color: white; }
th, td { border: 1px solid #ccc; padding: 6px 10px; }
th { background-color: #eee; text-align: left; }
input[type="text"] { width: 100%; box-sizing: border-box; padding: 4px; }
.btn { padding: 6px 12px; margin-right: 5px; cursor: pointer; border: 1px solid #888; background-color: #eaeaea; }
.sort-buttons { display: flex; gap: 5px; }
/* Neue Klasse für blockierte Felder */
.blocked-field { background-color: #ffdce0 !important; }
/* Neue Klasse für fehlerhafte IP-Adressen (roter Inset-Schatten) */
.invalid-ip { box-shadow: inset 0 0 5px red !important; outline: none; }
</style>
</head><body>
<h2>Hosts bearbeiten</h2>
<div class="hint-box">
<strong>Hinweis:</strong> Die MAC-Adresse dient der namentlichen Zuordnung von DHCP-Geräten.
Damit die Ersetzung von "DHCP" durch den echten Namen funktioniert, muss die IP-Adresse des DHCP-Gerätes im Netzwerk-Scan-Skript (die Liste der anzupingenden IPs) explizit mit erfasst werden.
Ohne einen erfolgreichen Ping auf die IP kann das System die MAC-Adresse nicht aus dem ARP-Cache auslesen. Bei Geräten mit fester IP wird die MAC-Adresse automatisch live ermittelt.
</div>
<?php if ($message): ?><div class="msg"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="search-container">
<input type="text" id="searchInput" class="search-box" placeholder="Tabelle durchsuchen (Name, IP, MAC, User...)">
<span id="clear-search-btn">✖</span>
</div>
<form method="post" id="mainForm">
<input type="hidden" name="action" id="formAction" value="save">
<input type="hidden" name="index" id="formIndex" value="">
<input type="hidden" name="direction" id="formDirection" value="">
<table>
<thead>
<tr>
<th><?= htmlspecialchars($header[0] ?? 'ip') ?></th>
<th><?= htmlspecialchars($header[1] ?? 'pc_name') ?></th>
<th><?= htmlspecialchars($header[2] ?? 'username') ?></th>
<th><?= htmlspecialchars($header[3] ?? 'mac') ?></th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($data_rows as $i => $row): ?>
<tr>
<td><input type="text" class="input-ip" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[0] ?? '') ?>"></td>
<td><input type="text" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[1] ?? '') ?>"></td>
<td><input type="text" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[2] ?? '') ?>"></td>
<td><input type="text" class="input-mac" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[3] ?? '') ?>"></td>
<td>
<div class="sort-buttons">
<button type="button" class="btn" onclick="moveRow(<?= $i ?>, 'up')">↑</button>
<button type="button" class="btn" onclick="moveRow(<?= $i ?>, 'down')">↓</button>
<button type="submit" name="delete" value="1" class="btn" onclick="document.getElementById('formIndex').value=<?= $i ?>;">Löschen</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div style="margin-top:15px;">
<button type="submit" name="save" class="btn">Speichern</button>
<button type="button" class="btn" onclick="addRow()">Neue Zeile hinzufügen</button>
</div>
</form>
<script>
const sInput = document.getElementById('searchInput');
const sBtn = document.getElementById('clear-search-btn');
// Filter-Logik
function applyFilter() {
const term = sInput.value.toLowerCase();
sBtn.style.display = term.length > 0 ? 'block' : 'none';
sessionStorage.setItem('editorSearch', term);
document.querySelectorAll('tbody tr').forEach(row => {
const inputs = row.querySelectorAll('input[type="text"]');
const isRowEmpty = Array.from(inputs).every(input => input.value.trim() === '');
const rowText = Array.from(inputs).map(i => i.value.toLowerCase()).join(' ');
// Zeile bleibt sichtbar wenn: Filter passt ODER Zeile ist komplett leer
row.style.display = (rowText.includes(term) || isRowEmpty) ? '' : 'none';
});
}
sInput.addEventListener('input', applyFilter);
sBtn.addEventListener('click', () => { sInput.value = ''; applyFilter(); });
// IP vs MAC Blockierungslogik und IP-Validierung
function handleMutualExclusion() {
document.querySelectorAll('tbody tr').forEach(row => {
const ipInput = row.querySelector('.input-ip');
const macInput = row.querySelector('.input-mac');
if (!ipInput || !macInput) return;
const updateState = () => {
const ipVal = ipInput.value.trim();
const hasIp = ipVal !== '';
const hasMac = macInput.value.trim() !== '';
// 1. Validierung der IP-Adresse (x.x.x.x wobei x 0-255 ist)
const ipRegex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
if (hasIp && !ipRegex.test(ipVal)) {
ipInput.classList.add('invalid-ip');
} else {
ipInput.classList.remove('invalid-ip');
}
// 2. Blockierungslogik
// Wenn IP befüllt und MAC leer ist -> MAC blockieren
if (hasIp && !hasMac) {
macInput.readOnly = true;
macInput.classList.add('blocked-field');
ipInput.readOnly = false;
ipInput.classList.remove('blocked-field');
}
// Wenn MAC befüllt und IP leer ist -> IP blockieren
else if (!hasIp && hasMac) {
ipInput.readOnly = true;
ipInput.classList.add('blocked-field');
macInput.readOnly = false;
macInput.classList.remove('blocked-field');
}
// Wenn beide leer oder versehentlich beide befüllt sind -> Beide freigeben
else {
ipInput.readOnly = false;
ipInput.classList.remove('blocked-field');
macInput.readOnly = false;
macInput.classList.remove('blocked-field');
}
};
ipInput.addEventListener('input', updateState);
macInput.addEventListener('input', updateState);
// Initialer Status beim Laden der Seite
updateState();
});
}
// Suchbegriff bei Reload wiederherstellen und Blockierungslogik starten
window.onload = () => {
const savedSearch = sessionStorage.getItem('editorSearch');
if (savedSearch) { sInput.value = savedSearch; applyFilter(); }
handleMutualExclusion();
};
function addRow() {
const form = document.getElementById('mainForm');
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'add_row'; input.value = '1';
form.appendChild(input); form.submit();
}
function moveRow(index, direction) {
document.getElementById('formAction').value = 'move';
document.getElementById('formIndex').value = index;
document.getElementById('formDirection').value = direction;
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'move'; input.value = '1';
document.getElementById('mainForm').appendChild(input);
document.getElementById('mainForm').submit();
}
</script>
</body></html>
5. User-Frontend 'netzwerk-monitor.htm'
Nun kommen wir zum wichtigsten dem User-Skript, dieses Skript ist für die visuelle Darstellung im Browser zuständig.
Es liest die vom Python-Backend erzeugte Datei 'netzwerk-scan.json' im Hintergrund asynchron per AJAX (Fetch API) alle 10 Sekunden ein.
Da die Aktualisierung direkt im Dokumentenmodell (DOM) der Seite stattfindet, flackert die Anzeige nicht und es erfolgt kein vollständiger Seiten-Reload.
Textmarkierungen, geöffnete Menüs oder die browserinterne Suche mit Strg+F werden also nicht unterbrochen.
Erstellen Sie die Datei im Webverzeichnis mit einem Editor:
nano /var/www/html/netzwerk-monitor.htm
pluma /var/www/html/netzwerk-monitor.htm
Fügen sie hier folgenden 'netzwerk-monitor.htm' Code ein:
<!DOCTYPE html><html lang="de"><head>
<meta charset="UTF-8">
<link rel="shortcut icon" type="image/x-icon" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor.ico">
<link rel="icon" type="image/x-icon" sizes="16x16 24x24 32x32 48x48 64x64 96x96 128x128 256x256" href="https://ctaas.de/box/netzwerk-monitor.ico">
<link rel="icon" type="image/png" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor.png">
<link rel="apple-touch-icon" href="https://ctaas.de/box/netzwerk-monitor.png">
<title>PC Überwachung</title>
<style>
body { font-family: Cousine, sans-serif; margin: 10px; color:#222 }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { padding: 6px 12px; border: 1px solid #ddd; }
th { cursor: pointer; background: #f2f2f2; }
/* Zebra-Striping nur für sichtbare Zeilen (wegen Filter) */
tbody tr:not([style*="display: none"]):nth-child(even) td:not(.online):not(.offline) { background: #f6f6f6; }
tbody tr:hover td:not(.online):not(.offline) { background: #fff9c4 !important; }
.online { background: #cfb !important; }
.offline { background: #fbb !important; }
th.asc::after { content: " ▲"; }
th.desc::after { content: " ▼"; }
.highlight td:not(:last-child) { background-color: #ffebcc !important; }
.flex-container { display: flex; align-items: center; }
.copy-btn {
width: 1em;
height: 1em;
margin-left: 0.5em;
cursor: pointer;
fill: none;
stroke: #666;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke 0.2s;
}
.copy-btn:hover {stroke: #000;}
/* Styling für die Suche und den CSV-Export */
.toolbar-container { display: flex; align-items: center; margin-bottom: 10px; }
.search-container { position: relative; display: inline-block; width: 100%; max-width: 400px; }
#search-input {
width: 100%;
padding: 6px 30px 6px 10px; /* Platz rechts für das X */
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
font-family: Cousine, sans-serif;
font-size: 1rem;
}
#clear-search-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #999;
font-weight: bold;
display: none; /* Standardmäßig ausgeblendet */
}
#clear-search-btn:hover { color: #333; }
#export-csv-btn { width: 1.5em; height: 1.5em; margin-left: 10px; }
</style>
</head><body>
<h1 style="color:#444;font-size:19pt;margin-bottom:8px;">Netzwerküberwachung</h1>
<p style="font-size: 0.8rem; color: #666; margin-top: 0; margin-bottom: 15px;" id="meta-info">Lade Daten...</p>
<div class="toolbar-container">
<div class="search-container">
<input type="text" id="search-input" placeholder="Suche nach Name, IP, MAC, User...">
<span id="clear-search-btn" title="Suche löschen">✖</span>
</div>
<svg id="export-csv-btn" class="copy-btn" viewBox="0 0 24 24" title="Tabelle als CSV kopieren">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</div>
<table id="monitor-table">
<thead>
<tr>
<th data-sort="string">PC / Device</th>
<th data-sort="string">Benutzername / Anmerkungen</th>
<th data-sort="string">IP-Adresse</th>
<th data-sort="string">MAC-Adresse</th>
<th data-sort="string">Status</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<script>
const table = document.getElementById('monitor-table');
const tbody = table.tBodies[0];
const headers = table.querySelectorAll('th');
const searchInput = document.getElementById('search-input');
const clearSearchBtn = document.getElementById('clear-search-btn');
const exportCsvBtn = document.getElementById('export-csv-btn');
let currentSort = JSON.parse(sessionStorage.getItem('sortState')) || { colIndex: 2, direction: 1 };
// Globale Variablen für die Statusanzeige
let lastUpdateTime = "...";
let globalOnline = 0;
let globalTotal = 0;
// Text in die Zwischenablage kopieren (für IP und MAC) inkl. HTTP-Fallback
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
// Für HTTPS oder localhost
navigator.clipboard.writeText(text).catch(err => {
console.error('Kopieren fehlgeschlagen', err);
});
} else {
// Fallback für HTTP im lokalen Netzwerk
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed"; // Verhindert Scrollen zur Box
textArea.style.left = "-999999px"; // Unsichtbar im Hintergrund
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
} catch (err) {
console.error('Fallback Kopieren fehlgeschlagen', err);
}
document.body.removeChild(textArea);
}
}
// CSV Export-Funktion
exportCsvBtn.addEventListener('click', () => {
let csvContent = "";
// Kopfzeilen extrahieren
const headerCells = Array.from(headers).map(th => {
return th.textContent.trim().replace(/;/g, ',');
});
csvContent += headerCells.join(';') + "\n";
// Nur sichtbare Datenzeilen extrahieren
const rows = Array.from(tbody.rows);
rows.forEach(row => {
if (row.style.display !== 'none') {
const cells = Array.from(row.cells).map(cell => {
// Zeilenumbrüche und überschüssige Leerzeichen entfernen, ; durch , ersetzen
let text = cell.textContent.trim().replace(/(\r\n|\n|\r)/gm, " ");
return text.replace(/;/g, ',');
});
csvContent += cells.join(';') + "\n";
}
});
// In die Zwischenablage kopieren
copyText(csvContent);
// Kurzes optisches Feedback (Tooltip ändert sich)
const originalTitle = exportCsvBtn.getAttribute('title');
exportCsvBtn.setAttribute('title', 'Kopiert!');
setTimeout(() => {
exportCsvBtn.setAttribute('title', originalTitle);
}, 2000);
});
// Hilfsfunktion: Aktualisiert den Text nur, wenn er sich geändert hat
function updateCell(el, text) {
if (el.textContent !== text) {
el.textContent = text;
}
}
// Filter-Logik
function applyFilter() {
const term = searchInput.value.toLowerCase();
const rows = Array.from(tbody.rows);
// X-Button ein/ausblenden
clearSearchBtn.style.display = term.length > 0 ? 'block' : 'none';
let filteredTotal = 0;
let filteredOnline = 0;
rows.forEach(row => {
// Durchsucht den gesamten Text der Zeile (alle Spalten)
if (row.textContent.toLowerCase().includes(term)) {
row.style.display = '';
filteredTotal++;
// Zählen der Online-Geräte im Filter
const statusCell = row.querySelector('.col-status');
if (statusCell && statusCell.textContent.trim() === 'Online') filteredOnline++;
} else {
row.style.display = 'none';
}
});
// Veraltungs-Check (>10 Min = 600000ms). Wandelt Datum um in JS-lesbares Format
let isStale = false;
if (lastUpdateTime !== "...") { let ts = new Date(lastUpdateTime.replace(' Uhr', '').replace(' / ', ' ').replace(/-/g, '/')).getTime(); if (Date.now() - ts > 600000) isStale = true; }
const staleMsg = isStale ? ` <span style="color:#f06; font-weight:normal;">| Hinweis: Server nicht erreichbar/Daten nicht aktuell.</span>` : "";
// Metadaten-Anzeige aktualisieren
const metaInfo = document.getElementById('meta-info');
if (term.length > 0) metaInfo.innerHTML = `Letzte Aktualisierung: ${lastUpdateTime} | Status-online: <b>${globalOnline}/${globalTotal}</b> (Filter-online: <b>${filteredOnline}/${filteredTotal}</b>)${staleMsg}`;
else metaInfo.innerHTML = `Letzte Aktualisierung: ${lastUpdateTime} | Status-online: <b>${globalOnline}/${globalTotal}</b>${staleMsg}`;
}
// Event-Listener für das Suchfeld und den X-Button
searchInput.addEventListener('input', applyFilter);
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
applyFilter();
searchInput.focus();
});
async function fetchAndUpdateData() {
try {
const response = await fetch('netzwerk-scan.json?t=' + new Date().getTime());
const data = await response.json();
// Globale Variablen für den Filterlauf aktualisieren
lastUpdateTime = data.update_time;
globalOnline = data.online_count;
globalTotal = data.total_count;
const currentIps = new Set(data.devices.map(d => d.ip));
// 1. Entferne Zeilen, die im JSON nicht mehr existieren
Array.from(tbody.rows).forEach(row => {
if (!currentIps.has(row.dataset.ip)) row.remove();
});
// 2. Aktualisiere oder erstelle Zeilen
data.devices.forEach(device => {
let row = document.querySelector(`tr[data-ip="${device.ip}"]`);
if (!row) {
row = tbody.insertRow();
row.dataset.ip = device.ip;
row.innerHTML = `
<td class="col-name"></td>
<td class="col-user"></td>
<td class="col-ip">
<div class="flex-container">
<a target="_blank" class="ip-link"></a>
<svg class="copy-btn copy-ip-btn" viewBox="0 0 24 24" title="IP kopieren">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</div>
</td>
<td class="col-mac-td">
<div class="flex-container">
<span class="col-mac"></span>
<svg class="copy-btn copy-mac-btn" viewBox="0 0 24 24" title="MAC kopieren" style="display:none;">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</div>
</td>
<td class="col-status"></td>
`;
// Klick-Highlighting
row.addEventListener('click', (e) => {
if(e.target.closest('.copy-btn')) return; // Klick auf Copy ignorieren
document.querySelectorAll('.highlight').forEach(r => r.classList.remove('highlight'));
row.classList.add('highlight');
sessionStorage.setItem('selectedIp', device.ip);
});
}
// Zell-Inhalte sicher updaten
updateCell(row.querySelector('.col-name'), device.pc_name);
updateCell(row.querySelector('.col-user'), device.username);
const link = row.querySelector('.ip-link');
updateCell(link, device.ip);
if(link.href !== `http://${device.ip}/`) link.href = `http://${device.ip}`;
row.querySelector('.copy-ip-btn').onclick = () => copyText(device.ip);
// MAC-Adresse und deren Copy-Button updaten
const macSpan = row.querySelector('.col-mac');
const macBtn = row.querySelector('.copy-mac-btn');
updateCell(macSpan, device.mac);
if (device.mac) {
macBtn.style.display = 'block';
macBtn.onclick = () => copyText(device.mac);
} else {
macBtn.style.display = 'none';
}
const statusCell = row.querySelector('.col-status');
updateCell(statusCell, device.status);
const statusClass = device.status === 'Online' ? 'online' : 'offline';
if (!statusCell.classList.contains(statusClass)) statusCell.className = `col-status ${statusClass}`;
// Gespeichertes Highlight wiederherstellen
if (sessionStorage.getItem('selectedIp') === device.ip) row.classList.add('highlight');
});
// Tabellensortierung direkt nach Daten-Update anwenden
if (currentSort.colIndex !== null) applySort(currentSort.colIndex, currentSort.direction);
// Filter nach Daten-Update (Reload) erneut anwenden, damit Suchergebnisse und Statuszahlen stabil bleiben
applyFilter();
} catch (error) {
console.error('Fehler beim Laden der JSON:', error);
// Trigger applyFilter um ggf. Offline-Warnung anzuzeigen, wenn Server down ist
applyFilter();
}
}
// Sortierlogik
function applySort(colIndex, direction) {
const rows = Array.from(tbody.rows);
const sortType = headers[colIndex].getAttribute('data-sort');
rows.sort((a, b) => {
let aVal = a.cells[colIndex].textContent.trim();
let bVal = b.cells[colIndex].textContent.trim();
if (colIndex === 2) { // IP Adresse
const ipToTuple = ip => ip.split('.').map(Number);
const aParts = ipToTuple(aVal);
const bParts = ipToTuple(bVal);
for (let i = 0; i < 4; i++) {
if (aParts[i] < bParts[i]) return -direction;
if (aParts[i] > bParts[i]) return direction;
}
return 0;
} else if (sortType === 'string') {
return direction * aVal.localeCompare(bVal, 'de');
}
return direction * (aVal - bVal);
});
tbody.append(...rows);
headers.forEach((th, i) => {
th.classList.remove('asc', 'desc');
if (i === colIndex) th.classList.add(direction === 1 ? 'asc' : 'desc');
});
}
// Event-Listener für Spaltenköpfe
headers.forEach((header, i) => {
header.addEventListener('click', () => {
let direction = 1;
if (currentSort.colIndex === i) direction = -currentSort.direction;
currentSort = { colIndex: i, direction: direction };
sessionStorage.setItem('sortState', JSON.stringify(currentSort));
applySort(i, direction);
});
});
// Initialer Start und Intervall
fetchAndUpdateData();
setInterval(fetchAndUpdateData, 10000);
</script>
</body></html>
Das Frontend bietet folgende Funktionen:
- Anzeige des exakten Zeitstempels der letzten Aktualisierung sowie eine Gesamtzählung der Online-Geräte.
- Automatische Veraltungswarnung: Sollte das Python-Backend im Hintergrund abstürzen oder die JSON-Datei aus anderen Gründen für mehr als 10 Minuten nicht aktualisiert werden,
erscheint in der Statuszeile automatisch ein rot formatierter Warnhinweis: 'Hinweis: Server nicht erreichbar/Daten nicht aktuell.'
- Eine übersichtliche Tabelle mit den Spalten: PC/Device, Benutzername/Anmerkungen, IP-Adresse, MAC-Adresse und Status (Online/Offline) farblich hervorgehoben.
- Komfortable Sortierung:
Ein Klick auf die Spaltenüberschriften sortiert die Tabelle alphabetisch oder bei der IP-Adresse korrekt numerisch nach den vier Oktetten auf- oder absteigend.
- Integrierte Live-Suche (Filter):
Ein Textfeld filtert die gesamte Tabelle spaltenübergreifend in Echtzeit während der Eingabe.
Nicht zutreffende Zeilen werden ausgeblendet. Über den "✖"-Button kann die Suche/Filterung wieder aufgehoben werden.
- Dynamische Statusanzeige:
Bei aktivem Filter wird die Online-Zählung in der Statuszeile um eine gefilterte Statistik erweitert,
z. B. 'Status-online: 120/150 (Filter-online: 5/10)'.
So lässt sich beim Filtern nach Gruppen wie "Handy" oder "Drucker" sofort ablesen, wie viele Geräte dieser Kategorie aktiv sind.
- Copy-to-Clipboard:
Hinter jeder IP- und MAC-Adresse befindet sich ein dezentes SVG-Icon.
Ein Klick darauf kopiert den Wert in die Zwischenablage. Da moderne Browser die "navigator.clipboard"-API im lokalen Netzwerk ohne HTTPS blockieren,
verfügt der Code über einen vollautomatischen Fallback, der die Daten über ein temporäres Textfeld mittels "execCommand" auch unter reinem HTTP verlässlich kopiert.
- CSV-Export:
Ein Klick auf das Export-Icon neben dem Suchfeld generiert eine CSV-Struktur aller aktuell sichtbaren (gefilterten) Zeilen inklusive Kopfzeile.
Die Werte werden standardmäßig mit einem Semikolon ';' getrennt, was den direkten Import in LibreOffice Calc oder Microsoft Excel ermöglicht.
Eventuell in den Textfeldern vorhandene Semikolons werden beim Export automatisch durch Kommas ','ersetzt, um Layoutverschiebungen in der Tabellenkalkulation zu verhindern.
6. Der Zugriff auf den Live Netzwerkscanner & Editor im Netzwerk erflogt einfach über einen beliebigen Webbrowser
Nachdem die Konfiguration abgeschlossen ist und die Ausgabedatei im Verzeichnis abgelegt wurde,
ist der Netzwerk-Monitor wie folgt erreichbar:
http://127.0.0.1 (localhost) - direkt am Server
http://192.168.x.x/netzwerk-monitor.htm - von jedem anderen Arbeitsplatzrechner im selben lokalen Netzwerk (LAN)
Die Seite aktualisiert sich alle 10 Sekunden. Sie spiegelt somit praktisch einen Live Status aller Systeme im Netzwerk wieder.
Bzw. den Web-Editor kann analog dazu über folgende URL aufgerufen:
http://127.0.0.1/netzwerk-hosts-editor.php - vom Server
http://192.168.x.x/netzwerk-hosts-editor.php - aus dem LAN Server
7. Anmerkungen
IP-Adressen, E-Mailadressen, Namen u. ä. wurden für die Dokumentation geändert, hacken ist also zwecklos.
Die Nutzung der Anleitung erfolgt auf eigene Gefahr, für jegliche Schäden wird keine Garantie/Haftung übernommen.
Die Dokumentation entstand aus verschiedenen Tests, sowie produktiven Installationen und stellt eine Zusammenfassung wichtiger und empfohlener Schritte dar.
Bevor sie eventuell Fragen stellen bitte ich sie die Dokumentation komplett zu lesen.
Hinweise auf Fehler, Anregungen, Danksagungen oder ähnliches sind immer willkommen.
Wichtige Ergänzungen:
- Der Scanner funktioniert nur sinnvoll wenn man pings im lokalen Netzwerk erlaubt.
- Bei mobilen Geräten sollte/muss man damit es sinnvoll funktioniert die anonymen Wechsel der MAC Adressen deaktivieren.
- Ich empfehle daher einen WLAN-Router auf dem man einen MAC-Filter zusätzlich betreibt somit sind nur dort zugelassene Geräte möglich.
- Pings kann man als Datenschutz zwar ausschalten da die pings aber nur Lokal im LAN bleiben sehe ich persönlich hier kein Problem.
- Pings extern aus dem Internet werden in der Regel nie von der Firwall (meist im Router) durchgelassen.
- Pings belegen extem wenig Netzwerkbandbreite, die ständigen pings sind vernachlässigbar.
- Ggf. können auf Client Geräten installierte Virenscanner/Firewalls je nach Hersteller häufige pings verhindern, hier müsst ihr selbst entscheiden.
- Ich nutze dieses Skript aktuell im Netzen mit ~ 150 Geräten in einem LAN ohne Fehler und es erleichtert mir die Arbeit sehr.
-- Ende --
Diese Seite ist neutral und unabhängig.
Alle Anleitungen stehen zu 100 %
kostenlos zur Verfügung.
Die Finanzierung erfolgt teilweise
über Werbung und Partnerprovisionen.
Danke wenn Du mich dabei unterstützt.
Alle Infos zum Bedanken findest Du hier.