From 402537805de551513dd5728e99f1cb0ae8a49765 Mon Sep 17 00:00:00 2001 From: Andreas Rudolph Date: Mon, 29 Jun 2026 15:18:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Netzwerk-Modul=20v0.5.0=20=E2=80=93=20I?= =?UTF-8?q?mport,=20Ger=C3=A4te-Tracking,=20Ereignislog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 22 +- app/Http/Controllers/NetworkController.php | 166 +++++++++++++ app/Models/NetworkDevice.php | 47 ++++ app/Models/NetworkDeviceEvent.php | 56 +++++ app/Models/NetworkHost.php | 25 ++ app/Models/NetworkScan.php | 30 +++ app/Services/NetworkScanImporter.php | 222 ++++++++++++++++++ ...6_29_100001_create_network_scans_table.php | 30 +++ ...29_100002_create_network_devices_table.php | 33 +++ ...6_29_100003_create_network_hosts_table.php | 37 +++ ...004_create_network_device_events_table.php | 32 +++ .../components/application-logo.blade.php | 17 +- resources/views/layouts/navigation.blade.php | 13 +- resources/views/network/device.blade.php | 186 +++++++++++++++ resources/views/network/devices.blade.php | 105 +++++++++ resources/views/network/import.blade.php | 46 ++++ resources/views/network/index.blade.php | 121 ++++++++++ resources/views/network/scan.blade.php | 64 +++++ resources/views/welcome.blade.php | 5 +- routes/web.php | 17 ++ tailwind.config.js | 2 + 21 files changed, 1269 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/NetworkController.php create mode 100644 app/Models/NetworkDevice.php create mode 100644 app/Models/NetworkDeviceEvent.php create mode 100644 app/Models/NetworkHost.php create mode 100644 app/Models/NetworkScan.php create mode 100644 app/Services/NetworkScanImporter.php create mode 100644 database/migrations/2026_06_29_100001_create_network_scans_table.php create mode 100644 database/migrations/2026_06_29_100002_create_network_devices_table.php create mode 100644 database/migrations/2026_06_29_100003_create_network_hosts_table.php create mode 100644 database/migrations/2026_06_29_100004_create_network_device_events_table.php create mode 100644 resources/views/network/device.blade.php create mode 100644 resources/views/network/devices.blade.php create mode 100644 resources/views/network/import.blade.php create mode 100644 resources/views/network/index.blade.php create mode 100644 resources/views/network/scan.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6adc8..85148bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/). --- +## [0.5.0] - 2026-06-29 + +### Added +- Netzwerk-Modul: Menüpunkt „Netzwerk" auf Ebene 0 für alle eingeloggten Benutzer +- Import von Angry IP Scanner `.txt`-Exporten (tab-getrennt) via Datei-Upload +- Automatische Erkennung und Speicherung von Netzwerkgeräten anhand MAC-Adresse +- Chronologische Scan-Sessions mit Metadaten (Subnetz, Quelle, Gesamt-/Online-Hosts) +- Änderungserkennung: neue Geräte, IP-Wechsel, Online/Offline-Statuswechsel +- Ereignis-Protokoll pro Gerät mit Bestätigungs-Workflow (✓ Bestätigen) +- Geräte-Detailansicht: Stammdaten, Bezeichnung, Notizen, IP-Verlauf, Ereignislog +- Geräte-Übersicht mit Suche und Statusfilter (Online/Offline) +- Scan-Detailansicht mit vollständiger Host-Tabelle +- Manuelle Notizen zu Geräten hinzufügbar +- 4 neue Datenbanktabellen: `network_scans`, `network_devices`, `network_hosts`, `network_device_events` +- `NetworkScanImporter`-Service für Parser-Logik (MAC-Normalisierung, Spalten-Aliase) +- `NetworkController` mit 9 Routen + +--- + ## [0.4.0] - 2026-06-29 ### Added @@ -62,7 +81,8 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/). - Grundlegende PHP-Projektstruktur (public/, src/, config/) - composer.json, .gitignore, README.md -[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.4.0...HEAD +[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.5.0...HEAD +[0.5.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.4.0...v0.5.0 [0.4.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.3.0...v0.4.0 [0.3.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.2.0...v0.3.0 [0.2.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.1.0...v0.2.0 diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php new file mode 100644 index 0000000..e0a2921 --- /dev/null +++ b/app/Http/Controllers/NetworkController.php @@ -0,0 +1,166 @@ +first(); + $totalDevices = NetworkDevice::count(); + $onlineDevices = NetworkDevice::where('status', 'online')->count(); + $recentEvents = NetworkDeviceEvent::with('device') + ->where('documented', false) + ->latest() + ->take(10) + ->get(); + $scans = NetworkScan::latest()->take(10)->get(); + + return view('network.index', compact( + 'latestScan', 'totalDevices', 'onlineDevices', 'recentEvents', 'scans' + )); + } + + // --- Alle Geräte --- + public function devices(Request $request): View + { + $query = NetworkDevice::with('events') + ->orderBy('current_ip'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + if ($request->filled('search')) { + $s = $request->search; + $query->where(function ($q) use ($s) { + $q->where('current_ip', 'like', "%{$s}%") + ->orWhere('mac_address', 'like', "%{$s}%") + ->orWhere('hostname', 'like', "%{$s}%") + ->orWhere('label', 'like', "%{$s}%") + ->orWhere('mac_vendor', 'like', "%{$s}%"); + }); + } + + $devices = $query->paginate(50)->withQueryString(); + + return view('network.devices', compact('devices')); + } + + // --- Geräte-Detail --- + public function device(NetworkDevice $device): View + { + $device->load(['events.documentedBy', 'hosts.scan']); + $ipHistory = $device->hosts() + ->select('ip_address', 'status', 'ping_ms', 'created_at') + ->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id') + ->orderByDesc('network_hosts.created_at') + ->take(50) + ->get(); + + return view('network.device', compact('device', 'ipHistory')); + } + + // --- Gerät: Label/Notiz speichern --- + public function updateDevice(Request $request, NetworkDevice $device): RedirectResponse + { + $validated = $request->validate([ + 'label' => ['nullable', 'string', 'max:100'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]); + + $oldLabel = $device->label; + $device->update($validated); + + if ($oldLabel !== $validated['label']) { + NetworkDeviceEvent::create([ + 'device_id' => $device->id, + 'event_type' => 'label_changed', + 'old_value' => $oldLabel, + 'new_value' => $validated['label'], + 'description'=> 'Bezeichnung manuell geändert', + 'documented' => true, + 'documented_by' => auth()->id(), + 'documented_at' => now(), + ]); + } + + return redirect()->route('network.device', $device) + ->with('success', 'Gerät aktualisiert.'); + } + + // --- Ereignis dokumentieren --- + public function documentEvent(Request $request, NetworkDeviceEvent $event): RedirectResponse + { + $request->validate([ + 'description' => ['nullable', 'string', 'max:500'], + ]); + + $event->update([ + 'documented' => true, + 'documented_by' => auth()->id(), + 'documented_at' => now(), + 'description' => $request->description ?? $event->description, + ]); + + return back()->with('success', 'Ereignis dokumentiert.'); + } + + // --- Manuelle Notiz hinzufügen --- + public function addNote(Request $request, NetworkDevice $device): RedirectResponse + { + $request->validate([ + 'description' => ['required', 'string', 'max:500'], + ]); + + NetworkDeviceEvent::create([ + 'device_id' => $device->id, + 'event_type' => 'manual_note', + 'description' => $request->description, + 'documented' => true, + 'documented_by' => auth()->id(), + 'documented_at' => now(), + ]); + + return back()->with('success', 'Notiz hinzugefügt.'); + } + + // --- Import --- + public function showImport(): View + { + return view('network.import'); + } + + public function import(Request $request, NetworkScanImporter $importer): RedirectResponse + { + $request->validate([ + 'scan_file' => ['required', 'file', 'mimes:txt,csv', 'max:5120'], + ]); + + $path = $request->file('scan_file')->store('imports', 'local'); + $fullPath = storage_path('app/' . $path); + + $scan = $importer->importAngryIpScannerFile($fullPath, auth()->id()); + + return redirect()->route('network.scan', $scan) + ->with('success', "Import abgeschlossen: {$scan->online_hosts} online, {$scan->new_devices} neue Geräte."); + } + + // --- Scan-Detail --- + public function scan(NetworkScan $scan): View + { + $hosts = $scan->hosts() + ->orderByRaw("INET_ATON(ip_address)") + ->get(); + + return view('network.scan', compact('scan', 'hosts')); + } +} diff --git a/app/Models/NetworkDevice.php b/app/Models/NetworkDevice.php new file mode 100644 index 0000000..a985e8b --- /dev/null +++ b/app/Models/NetworkDevice.php @@ -0,0 +1,47 @@ + 'datetime', + 'last_seen_at' => 'datetime', + ]; + + public function hosts(): HasMany + { + return $this->hasMany(NetworkHost::class, 'device_id'); + } + + public function events(): HasMany + { + return $this->hasMany(NetworkDeviceEvent::class, 'device_id')->latest(); + } + + public function getDisplayNameAttribute(): string + { + return $this->label + ?? $this->hostname + ?? $this->current_ip + ?? $this->mac_address; + } + + public function getStatusColorAttribute(): string + { + return match($this->status) { + 'online' => 'green', + 'offline' => 'red', + default => 'gray', + }; + } +} diff --git a/app/Models/NetworkDeviceEvent.php b/app/Models/NetworkDeviceEvent.php new file mode 100644 index 0000000..cf17a34 --- /dev/null +++ b/app/Models/NetworkDeviceEvent.php @@ -0,0 +1,56 @@ + 'boolean', + 'documented_at' => 'datetime', + ]; + + public function device(): BelongsTo + { + return $this->belongsTo(NetworkDevice::class, 'device_id'); + } + + public function documentedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'documented_by'); + } + + public function getEventLabelAttribute(): string + { + return match($this->event_type) { + 'new_device' => 'Neues Gerät', + 'ip_changed' => 'IP-Adresse geändert', + 'came_online' => 'Gerät online', + 'went_offline' => 'Gerät offline', + 'manual_note' => 'Manuelle Notiz', + 'label_changed' => 'Bezeichnung geändert', + default => $this->event_type, + }; + } + + public function getEventColorAttribute(): string + { + return match($this->event_type) { + 'new_device' => 'blue', + 'ip_changed' => 'yellow', + 'came_online' => 'green', + 'went_offline' => 'red', + 'manual_note' => 'purple', + 'label_changed' => 'gray', + default => 'gray', + }; + } +} diff --git a/app/Models/NetworkHost.php b/app/Models/NetworkHost.php new file mode 100644 index 0000000..dcbc4ce --- /dev/null +++ b/app/Models/NetworkHost.php @@ -0,0 +1,25 @@ +belongsTo(NetworkScan::class, 'scan_id'); + } + + public function device(): BelongsTo + { + return $this->belongsTo(NetworkDevice::class, 'device_id'); + } +} diff --git a/app/Models/NetworkScan.php b/app/Models/NetworkScan.php new file mode 100644 index 0000000..3445613 --- /dev/null +++ b/app/Models/NetworkScan.php @@ -0,0 +1,30 @@ + 'datetime', + ]; + + public function hosts(): HasMany + { + return $this->hasMany(NetworkHost::class, 'scan_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Services/NetworkScanImporter.php b/app/Services/NetworkScanImporter.php new file mode 100644 index 0000000..b722f23 --- /dev/null +++ b/app/Services/NetworkScanImporter.php @@ -0,0 +1,222 @@ + $h) { + $row[$h] = trim($cols[$i] ?? ''); + } + $rows[] = $row; + } + } + + return DB::transaction(function () use ($rows, $scanner, $subnet, $createdBy) { + $this->scan = NetworkScan::create([ + 'subnet' => $subnet, + 'source' => 'import', + 'scanner' => $scanner, + 'created_by' => $createdBy, + ]); + + foreach ($rows as $row) { + $this->processRow($row); + } + + $this->scan->update([ + 'total_hosts' => count($rows), + 'online_hosts' => $this->onlineHosts, + 'new_devices' => $this->newDevices, + 'changed_devices'=> $this->changedDevices, + ]); + + return $this->scan; + }); + } + + private function processRow(array $row): void + { + $ip = $this->extractColumn($row, ['IP']); + $ping = $this->extractColumn($row, ['Ping']); + $host = $this->extractColumn($row, ['Hostname']); + $mac = $this->normalizeMAC($this->extractColumn($row, ['MAC Addresse', 'MAC Address'])); + $vendor = $this->extractColumn($row, ['MAC Hersteller', 'MAC Vendor']); + $ttl = (int) $this->extractColumn($row, ['TTL']) ?: null; + $ports = $this->extractColumn($row, ['Ports']); + $netbios= $this->extractColumn($row, ['NetBIOS Info']); + $http = $this->extractColumn($row, ['HTTP Sender']); + $web = $this->extractColumn($row, ['Web Erkennung', 'Web Detection']); + + $pingMs = null; + $status = 'offline'; + + if (!empty($ping) && $ping !== 'n/a' && $ping !== '-') { + preg_match('/(\d+)/', $ping, $m); + $pingMs = isset($m[1]) ? (int)$m[1] : null; + $status = 'online'; + $this->onlineHosts++; + } + + // Host-Eintrag speichern + $host_record = NetworkHost::create([ + 'scan_id' => $this->scan->id, + 'ip_address' => $ip, + 'mac_address' => $mac ?: null, + 'hostname' => $host ?: null, + 'mac_vendor' => $vendor ?: null, + 'status' => $status, + 'ping_ms' => $pingMs, + 'netbios_info' => $netbios ?: null, + 'ttl' => $ttl, + 'ports' => $ports ?: null, + 'http_sender' => $http ?: null, + 'web_detection' => $web ?: null, + ]); + + // Geräte-Master nur wenn MAC bekannt + if (!empty($mac)) { + $this->processDevice($host_record, $ip, $mac, $host, $vendor, $netbios, $ttl, $ports, $status); + } + } + + private function processDevice( + NetworkHost $hostRecord, + string $ip, string $mac, ?string $hostname, + ?string $vendor, ?string $netbios, ?int $ttl, + ?string $ports, string $status + ): void { + $device = NetworkDevice::firstOrNew(['mac_address' => $mac]); + $isNew = !$device->exists; + + if ($isNew) { + $device->fill([ + 'current_ip' => $ip, + 'hostname' => $hostname, + 'mac_vendor' => $vendor, + 'status' => $status, + 'netbios_name' => $netbios, + 'ttl' => $ttl, + 'ports' => $ports, + 'first_seen_at'=> now(), + 'last_seen_at' => now(), + ])->save(); + + NetworkDeviceEvent::create([ + 'device_id' => $device->id, + 'scan_id' => $this->scan->id, + 'event_type' => 'new_device', + 'new_value' => $ip, + 'description'=> "Erstes Erscheinen: {$ip} ({$vendor})", + ]); + $this->newDevices++; + } else { + $events = []; + + // IP-Änderung erkennen + if ($device->current_ip !== $ip) { + $events[] = [ + 'event_type' => 'ip_changed', + 'old_value' => $device->current_ip, + 'new_value' => $ip, + 'description'=> "IP geändert von {$device->current_ip} zu {$ip}", + ]; + $this->changedDevices++; + } + + // Online/Offline-Status + if ($device->status !== $status) { + $events[] = [ + 'event_type' => $status === 'online' ? 'came_online' : 'went_offline', + 'old_value' => $device->status, + 'new_value' => $status, + 'description'=> $status === 'online' + ? "Gerät wieder online ({$ip})" + : "Gerät offline ({$device->current_ip})", + ]; + } + + $device->update([ + 'current_ip' => $ip, + 'hostname' => $hostname ?? $device->hostname, + 'status' => $status, + 'last_seen_at' => now(), + ]); + + foreach ($events as $event) { + NetworkDeviceEvent::create(array_merge($event, [ + 'device_id' => $device->id, + 'scan_id' => $this->scan->id, + ])); + } + } + + $hostRecord->update(['device_id' => $device->id]); + } + + private function extractColumn(array $row, array $keys): string + { + foreach ($keys as $key) { + if (isset($row[$key]) && $row[$key] !== '') { + return $row[$key]; + } + } + return ''; + } + + private function normalizeMAC(string $mac): string + { + // Verschiedene MAC-Formate normalisieren zu XX:XX:XX:XX:XX:XX + $clean = preg_replace('/[^a-fA-F0-9]/', '', $mac); + if (strlen($clean) !== 12) { + return ''; + } + return strtoupper(implode(':', str_split($clean, 2))); + } +} diff --git a/database/migrations/2026_06_29_100001_create_network_scans_table.php b/database/migrations/2026_06_29_100001_create_network_scans_table.php new file mode 100644 index 0000000..ea402fe --- /dev/null +++ b/database/migrations/2026_06_29_100001_create_network_scans_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('subnet'); // z.B. 192.168.86.0/24 + $table->string('source')->default('manual'); // manual | import | auto + $table->string('scanner')->nullable(); // z.B. "Angry IP Scanner 3.9.3" + $table->integer('total_hosts')->default(0); + $table->integer('online_hosts')->default(0); + $table->integer('new_devices')->default(0); + $table->integer('changed_devices')->default(0); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_scans'); + } +}; diff --git a/database/migrations/2026_06_29_100002_create_network_devices_table.php b/database/migrations/2026_06_29_100002_create_network_devices_table.php new file mode 100644 index 0000000..1138678 --- /dev/null +++ b/database/migrations/2026_06_29_100002_create_network_devices_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('mac_address')->unique(); // Primärschlüssel-Logik + $table->string('current_ip')->nullable(); + $table->string('hostname')->nullable(); + $table->string('mac_vendor')->nullable(); // Hersteller + $table->string('status')->default('unknown'); // online | offline | unknown + $table->string('netbios_name')->nullable(); + $table->integer('ttl')->nullable(); + $table->text('ports')->nullable(); + $table->text('notes')->nullable(); // manuelle Notizen + $table->string('label')->nullable(); // Benutzerfreundlicher Name + $table->timestamp('first_seen_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_devices'); + } +}; diff --git a/database/migrations/2026_06_29_100003_create_network_hosts_table.php b/database/migrations/2026_06_29_100003_create_network_hosts_table.php new file mode 100644 index 0000000..e827ee4 --- /dev/null +++ b/database/migrations/2026_06_29_100003_create_network_hosts_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('scan_id')->constrained('network_scans')->cascadeOnDelete(); + $table->foreignId('device_id')->nullable()->constrained('network_devices')->nullOnDelete(); + $table->string('ip_address'); + $table->string('mac_address')->nullable(); + $table->string('hostname')->nullable(); + $table->string('mac_vendor')->nullable(); + $table->string('status')->default('offline'); // online | offline | filtered + $table->integer('ping_ms')->nullable(); + $table->string('netbios_info')->nullable(); + $table->integer('ttl')->nullable(); + $table->text('ports')->nullable(); + $table->text('http_sender')->nullable(); + $table->text('web_detection')->nullable(); + $table->timestamps(); + + $table->index(['scan_id', 'ip_address']); + $table->index('mac_address'); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_hosts'); + } +}; diff --git a/database/migrations/2026_06_29_100004_create_network_device_events_table.php b/database/migrations/2026_06_29_100004_create_network_device_events_table.php new file mode 100644 index 0000000..d536c58 --- /dev/null +++ b/database/migrations/2026_06_29_100004_create_network_device_events_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('device_id')->constrained('network_devices')->cascadeOnDelete(); + $table->foreignId('scan_id')->nullable()->constrained('network_scans')->nullOnDelete(); + $table->string('event_type'); // new_device | ip_changed | came_online | went_offline | manual_note | label_changed + $table->string('old_value')->nullable(); + $table->string('new_value')->nullable(); + $table->text('description')->nullable(); + $table->boolean('documented')->default(false); // manuell bestätigt + $table->foreignId('documented_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('documented_at')->nullable(); + $table->timestamps(); + + $table->index(['device_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_device_events'); + } +}; diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php index 46579cf..c873248 100644 --- a/resources/views/components/application-logo.blade.php +++ b/resources/views/components/application-logo.blade.php @@ -1,3 +1,14 @@ - - - +@php + $logoPath = $appSettings['site_logo'] ?? ''; +@endphp + +@if(!empty($logoPath)) + {{ $appSettings['site_name'] ?? config('app.name') }}merge(['class' => 'object-contain']) }} + style="max-height: 2.25rem;" /> +@else + + + +@endif diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index f09298c..6d41dbe 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -1,4 +1,4 @@ -