feat: Netzwerk-Modul v0.5.0 – Import, Geräte-Tracking, Ereignislog
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user