diff --git a/CHANGELOG.md b/CHANGELOG.md index b29eefb..5c443fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,63 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/). --- +## [0.9.0] - 2026-07-02 + +### Added +- Geräte-Tracking ohne MAC-Adresse: nmap-Hosts aus Remote-Subnetzen (kein ARP möglich) werden jetzt als `network_devices`-Einträge erfasst (Tracking per Hostname, Fallback per IP) +- `mac_address` in `network_devices` ist jetzt nullable (mehrere NULL-Werte durch MySQL erlaubt) +- Dashboard: Separater Warnblock „⚠️ IP-Adressen-Wechsel erkannt" mit Anzeige alter IP → neuer IP, Gerätename, MAC, Quittier-Button inkl. Notizfeld +- Alle anderen Ereignisse auf dem Dashboard ebenfalls mit inline Notiz quittierbar +- Geräte-Detailseite: IP-Verlauf zeigt Hinweis wenn ein Gerät mehrere verschiedene IPs hatte, alle bekannten IPs als Tags, aktuelle IP hervorgehoben +- Globale Suche: `netbios_name` wird jetzt mitdurchsucht + +### Setup (einmalig) +``` +php artisan migrate +``` + +--- + +## [0.8.0] - 2026-07-02 + +### Added +- IP-Bemerkungen: Pro IP-Adresse je Segment eine Bemerkung hinterlegen (inline editierbar in der Scan-Detailansicht) +- Neue Tabelle `network_ip_notes` (Segment + IP als Unique-Key) +- Subnetz-Erkennung: Schaltfläche „🔍 Erkennen" in Segment anlegen/bearbeiten – ermittelt /24-Subnetze aus vorhandenen IP-Daten per API (`/network/detect-subnets`) +- Export Segment als Excel (`.xlsx`) via PhpSpreadsheet – enthält alle Hosts des letzten Scans inkl. Bemerkungen +- Export Segment als PDF via DomPDF – Querformat-Tabelle mit Hosts, Status, MAC, Hostname, Hersteller, Ping und Bemerkungen +- Export-Buttons (📊 Excel / 📄 PDF) auf Segment-Detailseite und Scan-Detailseite + +### Setup (einmalig ausführen) +``` +composer require phpoffice/phpspreadsheet barryvdh/laravel-dompdf +php artisan migrate +``` + +--- + +## [0.7.0] - 2026-07-02 + +### Added +- Automatischer Netzwerkscan via `nmap` pro Segment (Artisan Command `network:scan`) +- Scan-Zyklus pro Segment einstellbar: 5 / 15 / 30 / 60 / 360 / 720 / 1440 Minuten +- „Jetzt scannen"-Button direkt auf der Segment-Detailseite +- nmap-Parameter pro Segment konfigurierbar (z.B. `-sn`, `-sn -p 22,80,443`) +- Laravel Scheduler führt `network:scan` jede Minute aus und prüft fällige Segmente +- Auto-Refresh auf Segment-Detailseite nach manuellem Scan (prüft alle 10s) +- Chronologischer IP-Verlauf (`/network/history`) über alle Scans und Segmente +- Filter im IP-Verlauf: IP, MAC, Hostname, Segment, Status, Datumsbereich +- IP-Verlauf Auto-Refresh alle 60 Sekunden (ohne aktiven Filter) +- Navigation: neuer Menüpunkt „IP-Verlauf" +- `last_scanned_at` und `scan_interval_minutes` in Segment-Stammdaten + +### Setup (Cron für Scheduler) +``` +* * * * * cd /home/arudolph/Projekte/PHP/Network-MGMT && php artisan schedule:run >> /dev/null 2>&1 +``` + +--- + ## [0.6.0] - 2026-07-01 ### Added @@ -95,7 +152,10 @@ 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.6.0...HEAD +[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.9.0...HEAD +[0.9.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.8.0...v0.9.0 +[0.8.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.7.0...v0.8.0 +[0.7.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.6.0...v0.7.0 [0.6.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.5.0...v0.6.0 [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 diff --git a/app/Console/Commands/NetworkScanCommand.php b/app/Console/Commands/NetworkScanCommand.php new file mode 100644 index 0000000..b418bd3 --- /dev/null +++ b/app/Console/Commands/NetworkScanCommand.php @@ -0,0 +1,348 @@ +argument('segment'); + $force = $this->option('force'); + + if ($segmentId) { + $segments = NetworkSegment::where('id', $segmentId)->where('active', true)->get(); + if ($segments->isEmpty()) { + $this->error("Segment #{$segmentId} nicht gefunden oder inaktiv."); + return Command::FAILURE; + } + } else { + $segments = NetworkSegment::where('active', true)->get() + ->filter(fn($s) => $force || $s->isScanDue()); + } + + if ($segments->isEmpty()) { + $this->info('Kein Segment fällig. Mit --force erzwingen.'); + return Command::SUCCESS; + } + + foreach ($segments as $segment) { + $this->info("Scanne Segment: {$segment->name} ({$segment->subnet}) ..."); + $this->scanSegment($segment); + } + + return Command::SUCCESS; + } + + private function scanSegment(NetworkSegment $segment): void + { + // Subnetz aus "192.168.86.0/24" extrahieren, oder direkt als Range nutzen + $target = $segment->subnet; + $options = $segment->nmap_options ?: '-sn'; + + // nmap im XML-Modus ausführen + $cmd = "nmap {$options} -oX - " . escapeshellarg($target) . " 2>/dev/null"; + $xmlOutput = shell_exec($cmd); + + if (empty($xmlOutput)) { + $this->warn(" nmap lieferte keine Ausgabe. Ist nmap installiert? (sudo pacman -S nmap)"); + Log::warning("NetworkScan: nmap gab keine Ausgabe für Segment {$segment->id}"); + return; + } + + $hosts = $this->parseNmapXml($xmlOutput); + $this->info(" {$hosts['online']} online von " . count($hosts['rows']) . " gefunden."); + + DB::transaction(function () use ($segment, $hosts, $target) { + $scan = NetworkScan::create([ + 'segment_id' => $segment->id, + 'subnet' => $target, + 'source' => 'auto', + 'scanner' => 'nmap', + 'total_hosts' => count($hosts['rows']), + 'online_hosts' => $hosts['online'], + 'new_devices' => 0, + 'changed_devices' => 0, + 'created_by' => null, + ]); + + $newDevices = 0; + $changedDevices = 0; + + foreach ($hosts['rows'] as $row) { + $hostRecord = NetworkHost::create([ + 'scan_id' => $scan->id, + 'ip_address' => $row['ip'], + 'mac_address' => $row['mac'] ?: null, + 'hostname' => $row['hostname'] ?: null, + 'mac_vendor' => $row['vendor'] ?: null, + 'status' => $row['status'], + 'ping_ms' => null, + 'ttl' => null, + ]); + + if (!empty($row['mac'])) { + [$newDevices, $changedDevices] = $this->processDevice( + $hostRecord, $row, $scan->id, $newDevices, $changedDevices + ); + } elseif ($row['status'] === 'online') { + // Kein MAC (Remote-Subnet / kein Root-ARP) → Tracking per Hostname/IP + [$newDevices, $changedDevices] = $this->processDeviceWithoutMac( + $hostRecord, $row, $scan->id, $newDevices, $changedDevices + ); + } + } + + $scan->update([ + 'new_devices' => $newDevices, + 'changed_devices' => $changedDevices, + ]); + }); + + $segment->update(['last_scanned_at' => now()]); + $this->info(" ✓ Scan gespeichert."); + } + + private function parseNmapXml(string $xml): array + { + $rows = []; + $online = 0; + + try { + $doc = new \SimpleXMLElement($xml); + } catch (\Exception $e) { + Log::error('NetworkScan: nmap XML parse error: ' . $e->getMessage()); + return ['rows' => [], 'online' => 0]; + } + + foreach ($doc->host as $host) { + $status = (string) $host->status['state']; // up | down + $ip = ''; + $mac = ''; + $vendor = ''; + + foreach ($host->address as $addr) { + $type = (string) $addr['addrtype']; + if ($type === 'ipv4') { + $ip = (string) $addr['addr']; + } elseif ($type === 'mac') { + $mac = $this->normalizeMac((string) $addr['addr']); + $vendor = (string) $addr['vendor']; + } + } + + $hostname = ''; + foreach ($host->hostnames->hostname ?? [] as $hn) { + if ((string) $hn['type'] === 'PTR') { + $hostname = (string) $hn['name']; + break; + } + } + + if ($status === 'up') { + $online++; + } + + if ($ip) { + $rows[] = [ + 'ip' => $ip, + 'mac' => $mac, + 'vendor' => $vendor, + 'hostname' => $hostname, + 'status' => $status === 'up' ? 'online' : 'offline', + ]; + } + } + + return ['rows' => $rows, 'online' => $online]; + } + + private function processDevice( + NetworkHost $hostRecord, + array $row, + int $scanId, + int $newDevices, + int $changedDevices + ): array { + $device = NetworkDevice::firstOrNew(['mac_address' => $row['mac']]); + $isNew = !$device->exists; + + if ($isNew) { + $device->fill([ + 'current_ip' => $row['ip'], + 'hostname' => $row['hostname'] ?: null, + 'mac_vendor' => $row['vendor'] ?: null, + 'status' => $row['status'], + 'first_seen_at' => now(), + 'last_seen_at' => now(), + ])->save(); + + NetworkDeviceEvent::create([ + 'device_id' => $device->id, + 'scan_id' => $scanId, + 'event_type' => 'new_device', + 'new_value' => $row['ip'], + 'description' => "Erstes Erscheinen: {$row['ip']}" . ($row['vendor'] ? " ({$row['vendor']})" : ''), + ]); + $newDevices++; + } else { + $events = []; + + if ($device->current_ip !== $row['ip']) { + $events[] = [ + 'event_type' => 'ip_changed', + 'old_value' => $device->current_ip, + 'new_value' => $row['ip'], + 'description' => "IP geändert: {$device->current_ip} → {$row['ip']}", + ]; + $changedDevices++; + } + + if ($device->status !== $row['status']) { + $events[] = [ + 'event_type' => $row['status'] === 'online' ? 'came_online' : 'went_offline', + 'old_value' => $device->status, + 'new_value' => $row['status'], + 'description' => $row['status'] === 'online' + ? "Gerät wieder online ({$row['ip']})" + : "Gerät offline ({$device->current_ip})", + ]; + } + + $device->update([ + 'current_ip' => $row['ip'], + 'hostname' => $row['hostname'] ?: $device->hostname, + 'status' => $row['status'], + 'last_seen_at' => now(), + ]); + + foreach ($events as $event) { + NetworkDeviceEvent::create(array_merge($event, [ + 'device_id' => $device->id, + 'scan_id' => $scanId, + ])); + } + } + + $hostRecord->update(['device_id' => $device->id]); + + return [$newDevices, $changedDevices]; + } + + /** + * Geräte ohne MAC-Adresse tracken (z.B. Remote-Subnet wo kein ARP möglich ist). + * Primärschlüssel: Hostname (stabil per DNS). Fallback: IP-Adresse. + */ + private function processDeviceWithoutMac( + NetworkHost $hostRecord, + array $row, + int $scanId, + int $newDevices, + int $changedDevices + ): array { + // Vorhandenes Gerät suchen: erst per Hostname, dann per IP + $device = null; + + if (!empty($row['hostname'])) { + $device = NetworkDevice::whereNull('mac_address') + ->where('hostname', $row['hostname']) + ->first(); + } + + if (!$device) { + $device = NetworkDevice::whereNull('mac_address') + ->where('current_ip', $row['ip']) + ->first(); + } + + $isNew = !$device; + + if ($isNew) { + $device = new NetworkDevice(); + $device->fill([ + 'mac_address' => null, + 'current_ip' => $row['ip'], + 'hostname' => $row['hostname'] ?: null, + 'mac_vendor' => null, + 'status' => $row['status'], + 'first_seen_at' => now(), + 'last_seen_at' => now(), + ])->save(); + + NetworkDeviceEvent::create([ + 'device_id' => $device->id, + 'scan_id' => $scanId, + 'event_type' => 'new_device', + 'new_value' => $row['ip'], + 'description' => 'Erstes Erscheinen (kein MAC): ' . $row['ip'] + . ($row['hostname'] ? " ({$row['hostname']})" : ''), + ]); + $newDevices++; + } else { + $events = []; + + // IP-Wechsel: Hostname gleich, aber andere IP + if ($device->current_ip !== $row['ip']) { + $events[] = [ + 'event_type' => 'ip_changed', + 'old_value' => $device->current_ip, + 'new_value' => $row['ip'], + 'description' => "IP geändert: {$device->current_ip} → {$row['ip']}", + ]; + $changedDevices++; + } + + if ($device->status !== $row['status']) { + $events[] = [ + 'event_type' => $row['status'] === 'online' ? 'came_online' : 'went_offline', + 'old_value' => $device->status, + 'new_value' => $row['status'], + 'description' => $row['status'] === 'online' + ? "Gerät wieder online ({$row['ip']})" + : "Gerät offline ({$device->current_ip})", + ]; + } + + $device->update([ + 'current_ip' => $row['ip'], + 'hostname' => $row['hostname'] ?: $device->hostname, + 'status' => $row['status'], + 'last_seen_at' => now(), + ]); + + foreach ($events as $event) { + NetworkDeviceEvent::create(array_merge($event, [ + 'device_id' => $device->id, + 'scan_id' => $scanId, + ])); + } + } + + $hostRecord->update(['device_id' => $device->id]); + + return [$newDevices, $changedDevices]; + } + + private function normalizeMac(string $mac): string + { + $clean = preg_replace('/[^a-fA-F0-9]/', '', $mac); + if (strlen($clean) !== 12) { + return ''; + } + return strtoupper(implode(':', str_split($clean, 2))); + } +} diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php index a5234f0..65e63e2 100644 --- a/app/Http/Controllers/NetworkController.php +++ b/app/Http/Controllers/NetworkController.php @@ -4,26 +4,53 @@ namespace App\Http\Controllers; use App\Models\NetworkDevice; use App\Models\NetworkDeviceEvent; +use App\Models\NetworkHost; use App\Models\NetworkScan; use App\Models\NetworkSegment; use App\Services\NetworkScanImporter; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\View\View; class NetworkController extends Controller { + // --- Subnetz-Erkennung aus vorhandenen IP-Daten --- + public function detectSubnets(): JsonResponse + { + $subnets = DB::table('network_hosts') + ->whereNotNull('ip_address') + ->select(DB::raw("CONCAT(SUBSTRING_INDEX(ip_address, '.', 3), '.0/24') as subnet")) + ->distinct() + ->orderBy('subnet') + ->pluck('subnet') + ->toArray(); + + return response()->json($subnets); + } + // --- Dashboard --- public function dashboard(): View { $segments = NetworkSegment::withCount('scans')->orderBy('name')->get(); $totalDevices = NetworkDevice::count(); $onlineDevices = NetworkDevice::where('status', 'online')->count(); - $recentEvents = NetworkDeviceEvent::with('device') + + // IP-Wechsel separat → prominenter Block oben + $ipChangeEvents = NetworkDeviceEvent::with('device') ->where('documented', false) + ->where('event_type', 'ip_changed') ->latest() - ->take(10) + ->get(); + + // Alle anderen undokumentierten Ereignisse + $recentEvents = NetworkDeviceEvent::with('device') + ->where('documented', false) + ->where('event_type', '!=', 'ip_changed') + ->latest() + ->take(15) ->get(); // Letzten Scan pro Segment @@ -33,31 +60,113 @@ class NetworkController extends Controller ->groupBy('segment_id'); return view('network.dashboard', compact( - 'segments', 'totalDevices', 'onlineDevices', 'recentEvents', 'latestScans' + 'segments', 'totalDevices', 'onlineDevices', + 'recentEvents', 'ipChangeEvents', 'latestScans' )); } + // --- Chronologischer IP-Verlauf --- + public function history(Request $request): View + { + $query = \App\Models\NetworkHost::query() + ->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id') + ->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id') + ->leftJoin('network_devices', 'network_devices.id', '=', 'network_hosts.device_id') + ->select([ + 'network_hosts.*', + 'network_scans.created_at as scan_time', + 'network_scans.segment_id', + 'network_scans.scanner', + 'network_segments.name as segment_name', + 'network_devices.label as device_label', + ]); + + if ($request->filled('ip')) { + $query->where('network_hosts.ip_address', 'like', '%' . $request->ip . '%'); + } + if ($request->filled('mac')) { + $query->where('network_hosts.mac_address', 'like', '%' . $request->mac . '%'); + } + if ($request->filled('hostname')) { + $query->where(function ($q) use ($request) { + $q->where('network_hosts.hostname', 'like', '%' . $request->hostname . '%') + ->orWhere('network_devices.label', 'like', '%' . $request->hostname . '%'); + }); + } + if ($request->filled('segment')) { + $query->where('network_scans.segment_id', $request->segment); + } + if ($request->filled('status')) { + $query->where('network_hosts.status', $request->status); + } + if ($request->filled('from')) { + $query->where('network_scans.created_at', '>=', $request->from . ' 00:00:00'); + } + if ($request->filled('to')) { + $query->where('network_scans.created_at', '<=', $request->to . ' 23:59:59'); + } + + $entries = $query->orderByDesc('network_scans.created_at') + ->orderByRaw('INET_ATON(network_hosts.ip_address)') + ->paginate(100) + ->withQueryString(); + + $segments = \App\Models\NetworkSegment::orderBy('name')->get(); + + return view('network.history', compact('entries', 'segments')); + } + // --- Globale Suche --- public function search(Request $request): View { $q = trim($request->get('q', '')); $devices = collect(); + $hostResults = collect(); if (strlen($q) >= 2) { + // Suche in network_devices (MAC-basiert / bereits getracked) $devices = NetworkDevice::with('events') ->where(function ($query) use ($q) { - $query->where('current_ip', 'like', "%{$q}%") + $query->where('current_ip', 'like', "%{$q}%") ->orWhere('mac_address', 'like', "%{$q}%") ->orWhere('hostname', 'like', "%{$q}%") + ->orWhere('netbios_name','like', "%{$q}%") ->orWhere('label', 'like', "%{$q}%") ->orWhere('mac_vendor', 'like', "%{$q}%"); }) ->orderBy('current_ip') ->paginate(50) ->withQueryString(); + + // Zusätzlich: direkte Suche in network_hosts (auch ohne Device-Eintrag) + // Zeigt IPs/Hosts die in Scans gefunden wurden, aber noch kein Device-Record haben + $hostResults = NetworkHost::query() + ->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id') + ->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id') + ->whereNull('network_hosts.device_id') + ->where(function ($query) use ($q) { + $query->where('network_hosts.ip_address', 'like', "%{$q}%") + ->orWhere('network_hosts.mac_address', 'like', "%{$q}%") + ->orWhere('network_hosts.hostname', 'like', "%{$q}%"); + }) + ->select([ + 'network_hosts.ip_address', + 'network_hosts.mac_address', + 'network_hosts.hostname', + 'network_hosts.mac_vendor', + 'network_hosts.status', + 'network_hosts.ping_ms', + 'network_scans.created_at as scan_time', + 'network_segments.name as segment_name', + DB::raw('network_scans.id as scan_id'), + ]) + ->orderByDesc('network_scans.created_at') + ->limit(50) + ->get() + ->unique('ip_address'); // Jede IP nur einmal (neuester Fund) } - return view('network.search', compact('q', 'devices')); + return view('network.search', compact('q', 'devices', 'hostResults')); } // --- Alte index-Route umleiten --- @@ -203,6 +312,12 @@ class NetworkController extends Controller ->orderByRaw("INET_ATON(ip_address)") ->get(); - return view('network.scan', compact('scan', 'hosts')); + $notes = collect(); + if ($scan->segment_id) { + $notes = \App\Models\NetworkIpNote::where('segment_id', $scan->segment_id) + ->pluck('note', 'ip_address'); + } + + return view('network.scan', compact('scan', 'hosts', 'notes')); } } diff --git a/app/Http/Controllers/NetworkSegmentController.php b/app/Http/Controllers/NetworkSegmentController.php index cab113f..d21a3c8 100644 --- a/app/Http/Controllers/NetworkSegmentController.php +++ b/app/Http/Controllers/NetworkSegmentController.php @@ -2,9 +2,13 @@ namespace App\Http\Controllers; +use App\Models\NetworkIpNote; use App\Models\NetworkSegment; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; use Illuminate\View\View; class NetworkSegmentController extends Controller @@ -80,4 +84,96 @@ class NetworkSegmentController extends Controller return redirect()->route('network.segments.index') ->with('success', "Segment \"{$name}\" gelöscht."); } + + public function triggerScan(NetworkSegment $segment): RedirectResponse + { + if (!$segment->active) { + return back()->with('error', 'Segment ist inaktiv.'); + } + + // Scan im Hintergrund starten (non-blocking) + $projectPath = base_path(); + $phpBinary = PHP_BINARY; + $cmd = "cd {$projectPath} && {$phpBinary} artisan network:scan {$segment->id} --force > /dev/null 2>&1 &"; + exec($cmd); + + return redirect()->route('network.segments.show', $segment) + ->with('success', "Scan für \"{$segment->name}\" wurde gestartet. Ergebnisse erscheinen in Kürze."); + } + + // --- IP-Notiz speichern (Upsert per Segment + IP) --- + public function saveIpNote(Request $request, NetworkSegment $segment): JsonResponse + { + $request->validate([ + 'ip_address' => ['required', 'ip'], + 'note' => ['nullable', 'string', 'max:500'], + ]); + + NetworkIpNote::updateOrCreate( + ['segment_id' => $segment->id, 'ip_address' => $request->ip_address], + [ + 'note' => $request->note, + 'updated_by' => auth()->id(), + 'created_by' => auth()->id(), + ] + ); + + return response()->json(['ok' => true]); + } + + // --- Export: Excel (OpenSpout – kein ext-gd, PHP 8.5 kompatibel) --- + public function exportXlsx(NetworkSegment $segment) + { + $hosts = $this->getLatestScanHosts($segment); + $notes = NetworkIpNote::where('segment_id', $segment->id) + ->pluck('note', 'ip_address'); + + $filename = 'Segment_' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $segment->name) + . '_' . now()->format('Ymd_Hi') . '.xlsx'; + $tempFile = tempnam(sys_get_temp_dir(), 'net_export_') . '.xlsx'; + + $writer = new \OpenSpout\Writer\XLSX\Writer(); + $writer->openToFile($tempFile); + + $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([ + 'IP-Adresse', 'Status', 'Hostname', 'MAC-Adresse', 'Hersteller', 'Ping (ms)', 'Bemerkung', + ])); + + foreach ($hosts as $host) { + $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([ + $host->ip_address, + $host->status, + $host->hostname ?? '', + $host->mac_address ?? '', + $host->mac_vendor ?? '', + $host->ping_ms !== null ? (string) $host->ping_ms : '', + $notes[$host->ip_address] ?? '', + ])); + } + + $writer->close(); + + return response()->download($tempFile, $filename, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ])->deleteFileAfterSend(true); + } + + // --- Export: PDF (Browser-Print-View – kein PHP-PDF-Package nötig) --- + public function exportPdf(NetworkSegment $segment) + { + $hosts = $this->getLatestScanHosts($segment); + $notes = NetworkIpNote::where('segment_id', $segment->id) + ->pluck('note', 'ip_address'); + + return view('network.segments.export-pdf', compact('segment', 'hosts', 'notes')); + } + + private function getLatestScanHosts(NetworkSegment $segment) + { + $latestScan = $segment->scans()->latest()->first(); + if (!$latestScan) { + return collect(); + } + return $latestScan->hosts()->orderByRaw('INET_ATON(ip_address)')->get(); + } } diff --git a/app/Models/NetworkIpNote.php b/app/Models/NetworkIpNote.php new file mode 100644 index 0000000..be31f66 --- /dev/null +++ b/app/Models/NetworkIpNote.php @@ -0,0 +1,22 @@ +belongsTo(NetworkSegment::class); + } +} diff --git a/app/Models/NetworkSegment.php b/app/Models/NetworkSegment.php index eb7232f..4b220f4 100644 --- a/app/Models/NetworkSegment.php +++ b/app/Models/NetworkSegment.php @@ -10,14 +10,44 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough; class NetworkSegment extends Model { protected $fillable = [ - 'name', 'subnet', 'vlan_id', 'active', 'description', 'created_by', + 'name', 'subnet', 'vlan_id', 'active', 'description', + 'scan_interval_minutes', 'last_scanned_at', 'nmap_options', 'created_by', ]; protected $casts = [ - 'active' => 'boolean', - 'vlan_id' => 'integer', + 'active' => 'boolean', + 'vlan_id' => 'integer', + 'scan_interval_minutes' => 'integer', + 'last_scanned_at' => 'datetime', ]; + /** Ist ein automatischer Scan fällig? */ + public function isScanDue(): bool + { + if (!$this->active || !$this->scan_interval_minutes) { + return false; + } + if (!$this->last_scanned_at) { + return true; + } + return $this->last_scanned_at->addMinutes($this->scan_interval_minutes)->isPast(); + } + + /** Lesbare Intervall-Beschreibung */ + public function getScanIntervalLabelAttribute(): string + { + return match($this->scan_interval_minutes) { + 5 => 'alle 5 Minuten', + 15 => 'alle 15 Minuten', + 30 => 'alle 30 Minuten', + 60 => 'stündlich', + 360 => 'alle 6 Stunden', + 720 => 'alle 12 Stunden', + 1440 => 'täglich', + default => 'manuell', + }; + } + public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); diff --git a/composer.json b/composer.json index 0724ca3..6d585c9 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "php": "^8.3", "laravel/framework": "^13.8", "laravel/tinker": "^3.0", + "openspout/openspout": "^5.7", "spatie/laravel-permission": "^8.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 41de798..9d9a9aa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8e2e89c33485082dd08a993fad67945d", + "content-hash": "a17f728f96acdb49759b28983a8e062a", "packages": [ { "name": "brick/math", @@ -2540,6 +2540,99 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "openspout/openspout", + "version": "v5.7.2", + "source": { + "type": "git", + "url": "https://github.com/openspout/openspout.git", + "reference": "f383ae8ab4c735b6a6a0cef396e9799900584f3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openspout/openspout/zipball/f383ae8ab4c735b6a6a0cef396e9799900584f3e", + "reference": "f383ae8ab4c735b6a6a0cef396e9799900584f3e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "php": "~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ext-fileinfo": "*", + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.95.2", + "infection/infection": "^0.33.2", + "phpbench/phpbench": "^1.6.1", + "phpstan/phpstan": "^2.2.1", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^13.1.13" + }, + "suggest": { + "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", + "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenSpout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Adrien Loison", + "email": "adrien@box.com" + } + ], + "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way", + "homepage": "https://github.com/openspout/openspout", + "keywords": [ + "OOXML", + "csv", + "excel", + "memory", + "odf", + "ods", + "office", + "open", + "php", + "read", + "scale", + "spreadsheet", + "stream", + "write", + "xlsx" + ], + "support": { + "issues": "https://github.com/openspout/openspout/issues", + "source": "https://github.com/openspout/openspout/tree/v5.7.2" + }, + "funding": [ + { + "url": "https://paypal.me/filippotessarotto", + "type": "custom" + }, + { + "url": "https://github.com/Slamdunk", + "type": "github" + } + ], + "time": "2026-05-29T11:43:33+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", diff --git a/database/migrations/2026_07_01_100003_add_scan_config_to_network_segments.php b/database/migrations/2026_07_01_100003_add_scan_config_to_network_segments.php new file mode 100644 index 0000000..01e53c4 --- /dev/null +++ b/database/migrations/2026_07_01_100003_add_scan_config_to_network_segments.php @@ -0,0 +1,27 @@ +unsignedInteger('scan_interval_minutes')->nullable()->after('active'); + // Zeitpunkt des letzten automatischen Scans + $table->timestamp('last_scanned_at')->nullable()->after('scan_interval_minutes'); + // Optionale nmap-Parameter, z.B. "-sn" oder "-sn -p 22,80,443" + $table->string('nmap_options')->default('-sn')->after('last_scanned_at'); + }); + } + + public function down(): void + { + Schema::table('network_segments', function (Blueprint $table) { + $table->dropColumn(['scan_interval_minutes', 'last_scanned_at', 'nmap_options']); + }); + } +}; diff --git a/database/migrations/2026_07_02_100001_create_network_ip_notes_table.php b/database/migrations/2026_07_02_100001_create_network_ip_notes_table.php new file mode 100644 index 0000000..338de18 --- /dev/null +++ b/database/migrations/2026_07_02_100001_create_network_ip_notes_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('segment_id')->nullable()->index(); + $table->string('ip_address', 45)->index(); + $table->text('note')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->timestamps(); + + $table->unique(['segment_id', 'ip_address']); + $table->foreign('segment_id')->references('id')->on('network_segments')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_ip_notes'); + } +}; diff --git a/database/migrations/2026_07_02_200001_make_mac_address_nullable_in_network_devices.php b/database/migrations/2026_07_02_200001_make_mac_address_nullable_in_network_devices.php new file mode 100644 index 0000000..4b0ecae --- /dev/null +++ b/database/migrations/2026_07_02_200001_make_mac_address_nullable_in_network_devices.php @@ -0,0 +1,23 @@ + 🔍 Suche + + 📅 IP-Verlauf + ⬆️ Scan importieren diff --git a/resources/views/network/dashboard.blade.php b/resources/views/network/dashboard.blade.php index cbcbae4..0bdb6be 100644 --- a/resources/views/network/dashboard.blade.php +++ b/resources/views/network/dashboard.blade.php @@ -34,10 +34,83 @@

Offene Ereignisse

-

{{ $recentEvents->count() }}

+

+ {{ $recentEvents->count() + $ipChangeEvents->count() }} +

+ {{-- ⚠️ IP-Wechsel – prominenter Warnblock --}} + @if($ipChangeEvents->count() > 0) +
+
+

+ ⚠️ IP-Adressen-Wechsel erkannt + + {{ $ipChangeEvents->count() }} + +

+ Bitte prüfen und quittieren +
+
+ @foreach($ipChangeEvents as $event) +
+
+ {{-- Gerät und IP-Wechsel --}} +
+
+ + {{ $event->device->display_name }} + + @if($event->device->mac_address) + + {{ $event->device->mac_address }} + + @endif +
+
+ + {{ $event->old_value }} + + + + {{ $event->new_value }} + + {{ $event->created_at->format('d.m.Y H:i') }} +
+
+ {{-- Bestätigen mit Notiz --}} +
+ @csrf + + +
+
+ {{-- IP-Verlauf (sofern vorhanden) --}} + @php + $ipCount = $event->device->hosts() + ->selectRaw('DISTINCT ip_address') + ->count(); + @endphp + @if($ipCount > 1) +
+ 📋 Dieses Gerät hatte {{ $ipCount }} verschiedene IP-Adressen — + IP-Verlauf anzeigen → +
+ @endif +
+ @endforeach +
+
+ @endif + {{-- Globale Suche --}}
@@ -80,9 +153,7 @@ @foreach($segments as $segment) - @php - $lastScan = $latestScans->get($segment->id)?->first(); - @endphp + @php $lastScan = $latestScans->get($segment->id)?->first(); @endphp @@ -117,7 +188,7 @@ @endif
- {{-- Undokumentierte Ereignisse --}} + {{-- Sonstige undokumentierte Ereignisse --}} @if($recentEvents->count() > 0)
@@ -128,14 +199,14 @@
@foreach($recentEvents as $event) -
-
+
+
-
+
{{ $event->event_label }} @@ -143,10 +214,23 @@ · {{ $event->created_at->format('d.m.Y H:i') }} + @if($event->old_value && $event->new_value) + + {{ $event->old_value }}{{ $event->new_value }} + + @endif
- Detail → + + @csrf + + +
@endforeach
diff --git a/resources/views/network/device.blade.php b/resources/views/network/device.blade.php index 5b13331..d0407e2 100644 --- a/resources/views/network/device.blade.php +++ b/resources/views/network/device.blade.php @@ -101,10 +101,33 @@ {{-- IP-Verlauf --}} @if($ipHistory->count() > 0) + @php + $distinctIps = $ipHistory->pluck('ip_address')->unique()->values(); + @endphp
-
+

IP-Verlauf

+ @if($distinctIps->count() > 1) + + ⚠️ {{ $distinctIps->count() }} verschiedene IP-Adressen + + @endif
+ + {{-- Distinct IPs auf einen Blick --}} + @if($distinctIps->count() > 1) +
+

Bekannte IP-Adressen dieses Geräts:

+
+ @foreach($distinctIps as $dip) + + {{ $dip }}{{ $dip === $device->current_ip ? ' ← aktuell' : '' }} + + @endforeach +
+
+ @endif + @@ -116,9 +139,11 @@ @foreach($ipHistory as $h) - + - + diff --git a/resources/views/network/history.blade.php b/resources/views/network/history.blade.php new file mode 100644 index 0000000..b083225 --- /dev/null +++ b/resources/views/network/history.blade.php @@ -0,0 +1,175 @@ + + +
+

Chronologischer IP-Verlauf

+ {{ $entries->total() }} Einträge +
+
+ +
+
+ + {{-- Filter --}} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if(request()->hasAny(['ip','mac','hostname','segment','status','from','to'])) + + ✕ + + @endif +
+
+ + + {{-- Tabelle --}} +
+
{{ $h->created_at->format('d.m.Y H:i') }}{{ $h->ip_address }} + {{ $h->ip_address }} + {{ $h->status }}
+ + + + + + + + + + + + + + @forelse($entries as $entry) + + + + + + + + + + + @empty + + + + @endforelse + +
Datum / ZeitStatusIP-AdresseMAC-AdresseHostname / BezeichnungHerstellerSegmentPing
+ {{ \Carbon\Carbon::parse($entry->scan_time)->format('d.m.Y H:i') }} + @if($entry->scanner) +
{{ $entry->scanner }} + @endif +
+ + + + {{ $entry->status }} + + + + @if($entry->device_id) + + {{ $entry->ip_address }} + + @else + {{ $entry->ip_address }} + @endif + + @if($entry->mac_address) + {{ $entry->mac_address }} + @else + + @endif + + @if($entry->device_label) + {{ $entry->device_label }} + @if($entry->hostname) + ({{ $entry->hostname }}) + @endif + @else + {{ $entry->hostname ?? '—' }} + @endif + {{ $entry->mac_vendor ?? '—' }} + {{ $entry->segment_name ?? '—' }} + + {{ $entry->ping_ms !== null ? $entry->ping_ms . ' ms' : '—' }} +
+ Keine Einträge gefunden. +
+ + @if($entries->hasPages()) +
+ {{ $entries->links() }} +
+ @endif +
+ +
+
+ + {{-- Auto-Refresh alle 60 Sekunden wenn kein Filter aktiv --}} + @unless(request()->hasAny(['ip','mac','hostname','segment','status','from','to'])) + + @endunless + diff --git a/resources/views/network/scan.blade.php b/resources/views/network/scan.blade.php index 8937b36..2a0551d 100644 --- a/resources/views/network/scan.blade.php +++ b/resources/views/network/scan.blade.php @@ -1,9 +1,25 @@ -
- Netzwerk - / -

Scan {{ $scan->created_at->format('d.m.Y H:i') }}

+
+
+ @if($scan->segment) + {{ $scan->segment->name }} + / + @endif +

Scan {{ $scan->created_at->format('d.m.Y H:i') }}

+
+ @if($scan->segment) + + @endif
@@ -32,6 +48,7 @@ Hersteller Ping Ports + Bemerkung @@ -54,6 +71,25 @@ {{ $host->mac_vendor ?? '—' }} {{ $host->ping_ms ? $host->ping_ms . ' ms' : '—' }} {{ $host->ports ?? '—' }} + + @if($scan->segment_id) +
+ + {{ $notes[$host->ip_address] ?? '+ Bemerkung' }} + + +
+ @else + + @endif + @endforeach @@ -61,4 +97,61 @@
+ + @if($scan->segment_id) + + @endif diff --git a/resources/views/network/search.blade.php b/resources/views/network/search.blade.php index f0c5076..dd738e6 100644 --- a/resources/views/network/search.blade.php +++ b/resources/views/network/search.blade.php @@ -26,11 +26,17 @@ @if($q && strlen($q) < 2)

Mindestens 2 Zeichen eingeben.

@elseif($q) + @php $totalFound = $devices->total() + $hostResults->count(); @endphp

- {{ $devices->total() }} Ergebnis(se) für „{{ $q }}" über alle Segmente + {{ $totalFound }} Ergebnis(se) für „{{ $q }}" über alle Segmente

+ {{-- Ergebnisse aus network_devices (getracked) --}} + @if($devices->count() > 0)
+
+ Geräte (bekannt) +
@@ -44,20 +50,23 @@ - @forelse($devices as $device) + @foreach($devices as $device) - + @@ -69,20 +78,70 @@ class="text-xs text-indigo-600 hover:underline">Detail → - @empty - - - - @endforelse + @endforeach
{{ $device->current_ip }}{{ $device->mac_address }}{{ $device->mac_address ?? '—' }} @if($device->label) {{ $device->label }} ({{ $device->hostname }}) @else - {{ $device->hostname ?? '—' }} + {{ $device->hostname ?? $device->netbios_name ?? '—' }} + @endif + @if($device->netbios_name && $device->netbios_name !== $device->hostname) + NetBIOS: {{ $device->netbios_name }} @endif {{ $device->mac_vendor ?? '—' }}
Keine Geräte gefunden.
- @if($devices->hasPages())
{{ $devices->links() }}
@endif
+ @endif + + {{-- Ergebnisse aus network_hosts (Scan-Treffer ohne Device-Eintrag) --}} + @if($hostResults->count() > 0) +
+
+ + In Scan-Verlauf gefunden + + Noch kein Geräteeintrag — wird beim nächsten Scan angelegt +
+ + + + + + + + + + + + + + + @foreach($hostResults as $host) + + + + + + + + + + + @endforeach + +
StatusIP-AdresseMAC-AdresseHostnameHerstellerSegmentLetzter Scan
+ + {{ $host->ip_address }}{{ $host->mac_address ?? '—' }}{{ $host->hostname ?? '—' }}{{ $host->mac_vendor ?? '—' }}{{ $host->segment_name ?? '—' }} + {{ \Carbon\Carbon::parse($host->scan_time)->format('d.m.Y H:i') }} + + Scan → +
+
+ @endif + + @if($totalFound === 0) +
+ Keine Ergebnisse für „{{ $q }}" gefunden. +
+ @endif @endif
diff --git a/resources/views/network/segments/create.blade.php b/resources/views/network/segments/create.blade.php index 2216331..bd57674 100644 --- a/resources/views/network/segments/create.blade.php +++ b/resources/views/network/segments/create.blade.php @@ -22,9 +22,16 @@
- +
+ + +
+
@@ -41,6 +48,29 @@ placeholder="Kurze Beschreibung dieses Segments...">{{ old('description') }}
+
+ + +

Erfordert aktiven Laravel Scheduler (Cron).

+
+ +
+ + +

-sn = Ping-Scan (schnell) · -sn -p 22,80,443 = mit Ports

+
+
+ diff --git a/resources/views/network/segments/edit.blade.php b/resources/views/network/segments/edit.blade.php index d45ecb4..66f6f5c 100644 --- a/resources/views/network/segments/edit.blade.php +++ b/resources/views/network/segments/edit.blade.php @@ -22,9 +22,16 @@
- +
+ + +
+
@@ -40,6 +47,26 @@ class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">{{ old('description', $segment->description) }}
+
+ + +
+ +
+ + +

-sn = Ping-Scan · -sn -p 22,80,443 = mit Ports

+
+
active) ? 'checked' : '' }} @@ -61,4 +88,38 @@
+ diff --git a/resources/views/network/segments/export-pdf.blade.php b/resources/views/network/segments/export-pdf.blade.php new file mode 100644 index 0000000..acf2035 --- /dev/null +++ b/resources/views/network/segments/export-pdf.blade.php @@ -0,0 +1,165 @@ + + + + + + Segment {{ $segment->name }} – Export + + + + +
+
+

🌐 Segment: {{ $segment->name }}

+
+ Subnetz: {{ $segment->subnet }} + @if($segment->vlan_id)  ·  VLAN {{ $segment->vlan_id }} @endif +  ·  {{ $hosts->count() }} Hosts +  ·  {{ $hosts->where('status', 'online')->count() }} online +
+ Exportiert: {{ now()->format('d.m.Y H:i') }} + @if($segment->description)  ·  {{ $segment->description }} @endif +
+
+ +
+ + + + + + + + + + + + + + + @forelse($hosts as $host) + + + + + + + + + + @empty + + + + @endforelse + +
StatusIP-AdresseMAC-AdresseHostnameHerstellerPingBemerkung
+ {{ $host->status === 'online' ? '● Online' : '○ Offline' }} + {{ $host->ip_address }}{{ $host->mac_address ?? '—' }}{{ $host->hostname ?? '—' }}{{ $host->mac_vendor ?? '—' }}{{ $host->ping_ms !== null ? $host->ping_ms . ' ms' : '—' }}{{ $notes[$host->ip_address] ?? '' }}
+ Keine Hosts vorhanden. +
+ + + + + + + diff --git a/resources/views/network/segments/show.blade.php b/resources/views/network/segments/show.blade.php index 2fc0630..c385c65 100644 --- a/resources/views/network/segments/show.blade.php +++ b/resources/views/network/segments/show.blade.php @@ -6,14 +6,37 @@ /

{{ $segment->name }}

-
+
+ {{-- Auto-Refresh Indikator --}} + + + {{-- Jetzt scannen --}} + @if($segment->active) +
+ @csrf + +
+ @endif + - + Scan importieren + class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition"> + ⬆ Import + + + 📊 Excel + + + 📄 PDF + class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition"> Bearbeiten
@@ -39,6 +62,12 @@ {{ $segment->active ? 'Aktiv' : 'Inaktiv' }}

+
+

Auto-Scan

+

+ {{ $segment->scan_interval_label }} +

+

Scans gesamt

{{ $scans->total() }}

@@ -70,6 +99,7 @@

Scan-Historie

+
@@ -112,4 +142,33 @@ + + @if(session('success') && str_contains(session('success'), 'gestartet')) + {{-- Auto-Refresh nach "Jetzt scannen": alle 10s prüfen ob neuer Scan vorliegt --}} + + @endif diff --git a/routes/console.php b/routes/console.php index 3c9adf1..a382570 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,17 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Netzwerk-Scans automatisch ausführen +// Der Scheduler prüft jede Minute ob ein Segment fällig ist +Schedule::command('network:scan') + ->everyMinute() + ->withoutOverlapping() + ->runInBackground(); diff --git a/routes/web.php b/routes/web.php index f0bac02..9dae9fc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -42,12 +42,26 @@ Route::prefix('network') // Dashboard Route::get('/', [NetworkController::class, 'dashboard'])->name('dashboard'); + // Subnetz-Erkennung (JSON) + Route::get('/detect-subnets', [NetworkController::class, 'detectSubnets'])->name('detect-subnets'); + // Globale Suche Route::get('/search', [NetworkController::class, 'search'])->name('search'); - // Segmente (CRUD) + // Segmente (CRUD + Scan auslösen) Route::resource('segments', NetworkSegmentController::class) ->names('segments'); + Route::post('segments/{segment}/scan', [NetworkSegmentController::class, 'triggerScan']) + ->name('segments.scan'); + Route::post('segments/{segment}/ip-notes', [NetworkSegmentController::class, 'saveIpNote']) + ->name('segments.ip-notes'); + Route::get('segments/{segment}/export/xlsx', [NetworkSegmentController::class, 'exportXlsx']) + ->name('segments.export.xlsx'); + Route::get('segments/{segment}/export/pdf', [NetworkSegmentController::class, 'exportPdf']) + ->name('segments.export.pdf'); + + // Chronologischer IP-Verlauf + Route::get('/history', [NetworkController::class, 'history'])->name('history'); // Geräte Route::get('/devices', [NetworkController::class, 'devices'])->name('devices');