feat: Netzwerk-Modul v0.5.0 – Import, Geräte-Tracking, Ereignislog

This commit is contained in:
2026-06-29 15:18:49 +02:00
parent eb57be730b
commit 402537805d
21 changed files with 1269 additions and 7 deletions
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers;
use App\Models\NetworkDevice;
use App\Models\NetworkDeviceEvent;
use App\Models\NetworkScan;
use App\Services\NetworkScanImporter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NetworkController extends Controller
{
// --- Übersicht ---
public function index(): View
{
$latestScan = NetworkScan::latest()->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'));
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class NetworkDevice extends Model
{
protected $fillable = [
'mac_address', 'current_ip', 'hostname', 'mac_vendor',
'status', 'netbios_name', 'ttl', 'ports', 'notes',
'label', 'first_seen_at', 'last_seen_at',
];
protected $casts = [
'first_seen_at' => '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',
};
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NetworkDeviceEvent extends Model
{
protected $fillable = [
'device_id', 'scan_id', 'event_type', 'old_value',
'new_value', 'description', 'documented',
'documented_by', 'documented_at',
];
protected $casts = [
'documented' => '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',
};
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NetworkHost extends Model
{
protected $fillable = [
'scan_id', 'device_id', 'ip_address', 'mac_address',
'hostname', 'mac_vendor', 'status', 'ping_ms',
'netbios_info', 'ttl', 'ports', 'http_sender', 'web_detection',
];
public function scan(): BelongsTo
{
return $this->belongsTo(NetworkScan::class, 'scan_id');
}
public function device(): BelongsTo
{
return $this->belongsTo(NetworkDevice::class, 'device_id');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class NetworkScan extends Model
{
protected $fillable = [
'subnet', 'source', 'scanner', 'total_hosts',
'online_hosts', 'new_devices', 'changed_devices',
'notes', 'created_by',
];
protected $casts = [
'created_at' => 'datetime',
];
public function hosts(): HasMany
{
return $this->hasMany(NetworkHost::class, 'scan_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}
+222
View File
@@ -0,0 +1,222 @@
<?php
namespace App\Services;
use App\Models\NetworkDevice;
use App\Models\NetworkDeviceEvent;
use App\Models\NetworkHost;
use App\Models\NetworkScan;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class NetworkScanImporter
{
private NetworkScan $scan;
private int $newDevices = 0;
private int $changedDevices = 0;
private int $onlineHosts = 0;
/**
* Importiert eine Angry IP Scanner .txt Exportdatei.
*/
public function importAngryIpScannerFile(string $filePath, int $createdBy): NetworkScan
{
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$scanner = '';
$subnet = '';
$headers = [];
$rows = [];
foreach ($lines as $line) {
if (str_starts_with($line, 'Erstellt von') || str_starts_with($line, 'Created by')) {
$scanner = trim(str_replace(['Erstellt von ', 'Created by '], '', $line));
continue;
}
if (str_starts_with($line, 'Gescannt') || str_starts_with($line, 'Scanned')) {
// "Gescannt 192.168.86.0 - 192.168.86.255"
preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', $line, $m);
$subnet = $m[1] ?? '0.0.0.0';
continue;
}
if (str_starts_with($line, 'http') || str_starts_with($line, 'https')) {
continue; // URL-Zeile überspringen
}
// Header-Zeile (beginnt mit "IP")
if (str_starts_with($line, 'IP') && empty($headers)) {
$headers = preg_split('/\t+/', $line);
$headers = array_map('trim', $headers);
continue;
}
// Datenzeile
if (!empty($headers) && str_starts_with(trim($line), '') && preg_match('/^\d{1,3}\./', trim($line))) {
$cols = preg_split('/\t+/', $line);
$row = [];
foreach ($headers as $i => $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)));
}
}