feat: add firewall logs functionality to Proxmox Firewall component, enabling users to view recent firewall activity and enhance monitoring capabilities

This commit is contained in:
2025-12-01 14:11:05 -05:00
parent aa4115732e
commit 51b9997c28
2 changed files with 209 additions and 16 deletions

View File

@@ -19,6 +19,8 @@ class Firewall extends Component
public bool $showRuleForm = false; public bool $showRuleForm = false;
public array $networkAdapters = []; public array $networkAdapters = [];
public array $adapterFirewallStates = []; public array $adapterFirewallStates = [];
public array $firewallLogs = [];
public int $logLimit = 100;
// Rule creation/editing // Rule creation/editing
public ?int $editingRulePos = null; public ?int $editingRulePos = null;
@@ -52,6 +54,7 @@ class Firewall extends Component
$this->loadAliases(); $this->loadAliases();
$this->loadIpsets(); $this->loadIpsets();
$this->loadNetworkAdapters(); $this->loadNetworkAdapters();
$this->loadFirewallLogs();
} }
/** /**
@@ -192,6 +195,28 @@ class Firewall extends Component
public function startCreateRule(): void public function startCreateRule(): void
{ {
$this->resetRuleForm(); $this->resetRuleForm();
// Set default destination to first IP address
$allIps = collect();
if ($this->server->primaryIpv4) {
$allIps->push($this->server->primaryIpv4->ip);
}
if ($this->server->primaryIpv6) {
$allIps->push($this->server->primaryIpv6->ip);
}
foreach ($this->server->ipAddresses as $ipAddress) {
$allIps->push($ipAddress->ip);
}
$uniqueIps = $allIps->unique();
if ($uniqueIps->isNotEmpty()) {
$this->ruleDest = $uniqueIps->first();
}
// Set default interface to first network adapter
if (!empty($this->networkAdapters)) {
$this->ruleIface = $this->networkAdapters[0]['name'];
}
$this->editingRulePos = null; $this->editingRulePos = null;
$this->showRuleForm = true; $this->showRuleForm = true;
} }
@@ -248,6 +273,7 @@ class Firewall extends Component
if (!empty(trim($this->ruleSource))) { if (!empty(trim($this->ruleSource))) {
$payload['source'] = trim($this->ruleSource); $payload['source'] = trim($this->ruleSource);
} }
// Destination is required, always include it
if (!empty(trim($this->ruleDest))) { if (!empty(trim($this->ruleDest))) {
$payload['dest'] = trim($this->ruleDest); $payload['dest'] = trim($this->ruleDest);
} }
@@ -266,6 +292,7 @@ class Firewall extends Component
if (!empty(trim($this->ruleMacro))) { if (!empty(trim($this->ruleMacro))) {
$payload['macro'] = trim($this->ruleMacro); $payload['macro'] = trim($this->ruleMacro);
} }
// Interface is required, always include it
if (!empty(trim($this->ruleIface))) { if (!empty(trim($this->ruleIface))) {
$payload['iface'] = trim($this->ruleIface); $payload['iface'] = trim($this->ruleIface);
} }
@@ -687,6 +714,104 @@ class Firewall extends Component
} }
} }
public function loadFirewallLogs(): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log',
'get',
['limit' => $this->logLimit]
);
$rawLogs = $response->json()['data'] ?? [];
$this->firewallLogs = [];
foreach ($rawLogs as $logEntry) {
$logText = $logEntry['n'] ?? $logEntry['t'] ?? '';
if (empty($logText)) {
continue;
}
// Parse the log entry to extract simplified information
$parsed = $this->parseFirewallLog($logText);
if ($parsed) {
$this->firewallLogs[] = $parsed;
}
}
} catch (\Exception $e) {
$this->firewallLogs = [];
}
}
protected function parseFirewallLog(string $logText): ?array
{
// Example log: 202500009 2 tap202500009i0-IN 01/Dec/2025:09:00:39 -1000 REJECT: IN=fwbr202500009i0 OUT=fwbr202500009i0 PHYSIN=fwln202500009i0 PHYSOUT=tap202500009i0 MAC=8e:ef:c1:86:d4:20:58:9c:fc:10:ff:d3:08:00 SRC=24.210.66.222 DST=45.154.99.21 LEN=52 TOS=0x00 PREC=0x00 TTL=115 ID=58510 DF PROTO=TCP SPT=50692 DPT=22 SEQ=2584199599 ACK=0 WINDOW=65535 SYN
// Extract SRC, DST, TTL, PROTO, SPT, DPT, ACK
$result = [
'source' => null,
'destination' => null,
'ttl' => null,
'proto' => null,
'spt' => null,
'dpt' => null,
'ack' => null,
'action' => null,
'timestamp' => null,
];
// Extract action (REJECT, ACCEPT, DROP) - usually before the colon
if (preg_match('/(REJECT|ACCEPT|DROP):/i', $logText, $matches)) {
$result['action'] = strtoupper($matches[1]);
}
// Extract timestamp - look for date pattern like "01/Dec/2025:09:00:39"
if (preg_match('/(\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2})/', $logText, $matches)) {
$result['timestamp'] = $matches[1];
}
// Extract SRC
if (preg_match('/SRC=([^\s]+)/', $logText, $matches)) {
$result['source'] = $matches[1];
}
// Extract DST
if (preg_match('/DST=([^\s]+)/', $logText, $matches)) {
$result['destination'] = $matches[1];
}
// Extract TTL
if (preg_match('/TTL=(\d+)/', $logText, $matches)) {
$result['ttl'] = (int) $matches[1];
}
// Extract PROTO
if (preg_match('/PROTO=(\w+)/', $logText, $matches)) {
$result['proto'] = strtoupper($matches[1]);
}
// Extract SPT (Source Port)
if (preg_match('/SPT=(\d+)/', $logText, $matches)) {
$result['spt'] = (int) $matches[1];
}
// Extract DPT (Destination Port)
if (preg_match('/DPT=(\d+)/', $logText, $matches)) {
$result['dpt'] = (int) $matches[1];
}
// Extract ACK
if (preg_match('/ACK=(\d+)/', $logText, $matches)) {
$result['ack'] = (int) $matches[1];
}
// Only return if we have at least some data
if ($result['source'] || $result['destination'] || $result['proto']) {
return $result;
}
return null;
}
public function render() public function render()
{ {
return view('proxmox::firewall')->layoutData([ return view('proxmox::firewall')->layoutData([

View File

@@ -129,22 +129,23 @@
@if($adapter['mac']) @if($adapter['mac'])
<div>MAC: <code class="text-xs">{{ $adapter['mac'] }}</code></div> <div>MAC: <code class="text-xs">{{ $adapter['mac'] }}</code></div>
@endif @endif
@if($adapter['bridge'])
<div>Bridge: <code class="text-xs">{{ $adapter['bridge'] }}</code></div>
@endif
</div> </div>
<p class="text-xs text-base/50 mt-2">Enables or disables the firewall ability for this network adapter</p>
</div> </div>
<div class="flex items-center gap-3 ml-4"> <div class="flex flex-col items-center gap-2 ml-4">
<span class="text-sm font-medium text-base/70">Disabled</span> <span class="text-xs font-medium text-base/60">Firewall</span>
<label class="firewall-toggle-switch" wire:loading.attr="disabled"> <div class="flex items-center gap-3">
<input <span class="text-sm font-medium text-base/70">Disabled</span>
type="checkbox" <label class="firewall-toggle-switch" wire:loading.attr="disabled">
@if($adapterFirewallStates[$adapter['name']] ?? false) checked @endif <input
wire:click="toggleAdapterFirewall('{{ $adapter['name'] }}')" type="checkbox"
wire:loading.attr="disabled"> @if($adapterFirewallStates[$adapter['name']] ?? false) checked @endif
<span class="firewall-toggle-slider"></span> wire:click="toggleAdapterFirewall('{{ $adapter['name'] }}')"
</label> wire:loading.attr="disabled">
<span class="text-sm font-medium text-base/70">Enabled</span> <span class="firewall-toggle-slider"></span>
</label>
<span class="text-sm font-medium text-base/70">Enabled</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -260,7 +261,6 @@
<div> <div>
<label class="block text-sm font-medium mb-2">Destination</label> <label class="block text-sm font-medium mb-2">Destination</label>
<select wire:model="ruleDest" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background"> <select wire:model="ruleDest" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
<option value="">Any</option>
@php @php
$allIps = collect(); $allIps = collect();
if ($server->primaryIpv4) { if ($server->primaryIpv4) {
@@ -290,7 +290,6 @@
<div> <div>
<label class="block text-sm font-medium mb-2">Interface</label> <label class="block text-sm font-medium mb-2">Interface</label>
<select wire:model="ruleIface" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background"> <select wire:model="ruleIface" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
<option value="">Any</option>
@foreach($networkAdapters as $adapter) @foreach($networkAdapters as $adapter)
<option value="{{ $adapter['name'] }}">{{ $adapter['name'] }} ({{ ucfirst($adapter['model'] ?? 'Unknown') }})</option> <option value="{{ $adapter['name'] }}">{{ $adapter['name'] }} ({{ ucfirst($adapter['model'] ?? 'Unknown') }})</option>
@endforeach @endforeach
@@ -400,6 +399,75 @@
</div> </div>
@endif @endif
</div> </div>
<!-- Firewall Logs Card -->
<div class="bg-background border border-neutral rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<x-ri-file-list-3-line class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold">Firewall Logs</h2>
<p class="text-sm text-base/60 mt-1">Recent firewall activity and blocked/allowed connections</p>
</div>
</div>
<button
wire:click="loadFirewallLogs"
wire:loading.attr="disabled"
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
<x-ri-refresh-line class="size-4 inline mr-1" />
Refresh
</button>
</div>
@if(count($firewallLogs) > 0)
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-neutral">
<th class="text-left py-3 px-4 font-semibold text-sm">Timestamp</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Action</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Source</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Destination</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Protocol</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Source Port</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Dest Port</th>
<th class="text-left py-3 px-4 font-semibold text-sm">TTL</th>
<th class="text-left py-3 px-4 font-semibold text-sm">ACK</th>
</tr>
</thead>
<tbody>
@foreach($firewallLogs as $log)
<tr class="border-b border-neutral/20 hover:bg-background-secondary/50">
<td class="py-3 px-4 text-sm">{{ $log['timestamp'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">
<span class="px-2 py-1 rounded text-xs font-medium
{{ ($log['action'] ?? '') === 'ACCEPT' ? 'bg-success/20 text-success' : '' }}
{{ ($log['action'] ?? '') === 'REJECT' ? 'bg-warning/20 text-warning' : '' }}
{{ ($log['action'] ?? '') === 'DROP' ? 'bg-error/20 text-error' : '' }}">
{{ $log['action'] ?? '-' }}
</span>
</td>
<td class="py-3 px-4 text-sm"><code class="text-xs">{{ $log['source'] ?? '-' }}</code></td>
<td class="py-3 px-4 text-sm"><code class="text-xs">{{ $log['destination'] ?? '-' }}</code></td>
<td class="py-3 px-4 text-sm">{{ $log['proto'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ $log['spt'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ $log['dpt'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ $log['ttl'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ $log['ack'] ?? '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base/60">
<x-ri-file-list-3-line class="size-12 mx-auto mb-3 opacity-50" />
<p>No firewall logs available</p>
</div>
@endif
</div>
@else @else
<div class="bg-background-secondary border border-neutral/20 rounded-lg p-8 text-center"> <div class="bg-background-secondary border border-neutral/20 rounded-lg p-8 text-center">
<x-ri-shield-line class="size-16 mx-auto mb-4 opacity-50" /> <x-ri-shield-line class="size-16 mx-auto mb-4 opacity-50" />