Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/Firewall.php

1858 lines
67 KiB
PHP

<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Carbon\Carbon;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
use Paymenter\Extensions\Servers\Proxmox\Services\CoreRouterClient;
class Firewall extends Component
{
// Server & Core State
public bool $firewallEnabled = false;
public bool $loading = false;
public string $error = '';
public bool $showErrorModal = false;
// Network Adapters
public array $networkAdapters = [];
public array $adapterFirewallStates = [];
public array $adapterConfigs = []; // Store full network config strings
// Firewall Options (policies, filters)
public string $policyIn = 'DROP';
public string $policyOut = 'ACCEPT';
public bool $showPolicyInfoModal = false;
// Rules Management
public array $rules = [];
public bool $showRuleForm = false;
public ?int $editingRuleIndex = null;
public string $ruleAction = 'ACCEPT';
public string $ruleType = 'in';
public string $ruleSource = '';
public string $ruleDest = '';
public string $ruleProto = '';
public string $ruleDport = '';
public string $ruleSport = '';
public string $ruleComment = '';
public bool $ruleEnable = true;
public string $ruleMacro = '';
public string $ruleIface = '';
public string $ruleLog = 'nolog';
public bool $showDeleteRuleModal = false;
public ?int $deletingRuleIndex = null;
// Available Proxmox Macros
public array $availableMacros = [
'' => 'None',
'Amanda' => 'Amanda Backup',
'Auth' => 'Auth (identd)',
'BGP' => 'Border Gateway Protocol',
'BitTorrent' => 'BitTorrent traffic',
'BitTorrent32' => 'BitTorrent traffic (ports 32768:65535)',
'Ceph' => 'Ceph Storage Cluster',
'CVSup' => 'CVSup',
'DHCPfwd' => 'Forwarded DHCP traffic',
'DHCPv6fwd' => 'Forwarded DHCPv6 traffic',
'DNS' => 'Domain Name System',
'Distcc' => 'Distcc',
'FTP' => 'File Transfer Protocol',
'Git' => 'Git',
'HKP' => 'OpenPGP HTTP Keyserver',
'HTTP' => 'Hypertext Transfer Protocol',
'HTTPS' => 'Hypertext Transfer Protocol (SSL)',
'ICPV2' => 'Internet Cache Protocol V2',
'IMAP' => 'Internet Message Access Protocol',
'IMAPS' => 'Internet Message Access Protocol (SSL)',
'IPsec' => 'IPsec',
'IPsecah' => 'IPsec AH',
'IPsecnat' => 'IPsec NAT-T',
'IRC' => 'Internet Relay Chat',
'Jetdirect' => 'HP Jetdirect',
'L2TP' => 'Layer 2 Tunneling Protocol',
'LDAP' => 'Lightweight Directory Access Protocol',
'LDAPS' => 'Lightweight Directory Access Protocol (SSL)',
'MSNP' => 'Microsoft Notification Protocol',
'MSSQL' => 'Microsoft SQL Server',
'Mail' => 'Mail traffic (SMTP, SMTPS, Submission)',
'NFS' => 'Network File System',
'NNTP' => 'NNTP',
'NNTPS' => 'NNTPS',
'NTP' => 'Network Time Protocol',
'OSPF' => 'OSPF',
'OpenVPN' => 'OpenVPN',
'PCA' => 'Symantec PCAnywhere',
'PMG' => 'Proxmox Mail Gateway',
'POP3' => 'POP3',
'POP3S' => 'POP3S',
'PPtP' => 'Point-to-Point Tunneling Protocol',
'Ping' => 'ICMP echo request',
'PostgreSQL' => 'PostgreSQL',
'Printer' => 'Line Printer Daemon',
'RDP' => 'Microsoft Remote Desktop',
'RIP' => 'Routing Information Protocol',
'RNDC' => 'BIND remote management',
'Razor' => 'Razor',
'Rsyncd' => 'Rsync daemon',
'SANE' => 'SANE network scanner',
'SMB' => 'Microsoft SMB',
'SMTP' => 'Simple Mail Transfer Protocol',
'SMTPS' => 'Simple Mail Transfer Protocol (SSL)',
'SNMP' => 'Simple Network Management Protocol',
'SPAMD' => 'SpamAssassin daemon traffic',
'SSH' => 'Secure Shell',
'SVN' => 'Apache Subversion',
'SixXS' => 'SixXS IPv6 Deployment',
'SpiceProxy' => 'Proxmox VE SPICE Display Proxy',
'Squid' => 'Squid web proxy',
'Submission' => 'Mail message submission',
'Syslog' => 'Syslog protocol',
'TFTP' => 'Trivial File Transfer Protocol',
'Telnet' => 'Telnet',
'Telnets' => 'Telnet (SSL)',
'Time' => 'RFC 868 Time',
'Trcrt' => 'Traceroute',
'VNC' => 'VNC traffic',
'VNCL' => 'VNC Latin1',
'Web' => 'WWW traffic (HTTP and HTTPS)',
'Webcache' => 'Web Cache/Proxy',
'Webmin' => 'Webmin',
'Whois' => 'Whois'
];
// Macro Definitions with ports and protocols (as per Proxmox)
protected array $macroDefinitions = [
'Amanda' => ['proto' => 'tcp', 'dport' => '10080,10081,10082,10083'],
'Auth' => ['proto' => 'tcp', 'dport' => '113'],
'BGP' => ['proto' => 'tcp', 'dport' => '179'],
'BitTorrent' => ['proto' => 'tcp', 'dport' => '6881:6889'],
'BitTorrent32' => ['proto' => 'tcp', 'dport' => '32768:65535'],
'Ceph' => ['proto' => 'tcp', 'dport' => '6789,6800:7300'],
'CVSup' => ['proto' => 'tcp', 'dport' => '5999'],
'DHCPfwd' => ['proto' => 'udp', 'dport' => '67:68'],
'DHCPv6fwd' => ['proto' => 'udp', 'dport' => '546:547'],
'DNS' => ['proto' => 'udp', 'dport' => '53'],
'Distcc' => ['proto' => 'tcp', 'dport' => '3632'],
'FTP' => ['proto' => 'tcp', 'dport' => '21'],
'Git' => ['proto' => 'tcp', 'dport' => '9418'],
'HKP' => ['proto' => 'tcp', 'dport' => '11371'],
'HTTP' => ['proto' => 'tcp', 'dport' => '80'],
'HTTPS' => ['proto' => 'tcp', 'dport' => '443'],
'ICPV2' => ['proto' => 'udp', 'dport' => '3130'],
'IMAP' => ['proto' => 'tcp', 'dport' => '143'],
'IMAPS' => ['proto' => 'tcp', 'dport' => '993'],
'IPsec' => ['proto' => 'udp', 'dport' => '500'],
'IPsecah' => ['proto' => 'ah', 'dport' => ''],
'IPsecnat' => ['proto' => 'udp', 'dport' => '4500'],
'IRC' => ['proto' => 'tcp', 'dport' => '6667'],
'Jetdirect' => ['proto' => 'tcp', 'dport' => '9100'],
'L2TP' => ['proto' => 'udp', 'dport' => '1701'],
'LDAP' => ['proto' => 'tcp', 'dport' => '389'],
'LDAPS' => ['proto' => 'tcp', 'dport' => '636'],
'MSNP' => ['proto' => 'tcp', 'dport' => '1863'],
'MSSQL' => ['proto' => 'tcp', 'dport' => '1433'],
'Mail' => ['proto' => 'tcp', 'dport' => '25,465,587'],
'NFS' => ['proto' => 'tcp', 'dport' => '111,2049'],
'NNTP' => ['proto' => 'tcp', 'dport' => '119'],
'NNTPS' => ['proto' => 'tcp', 'dport' => '563'],
'NTP' => ['proto' => 'udp', 'dport' => '123'],
'OSPF' => ['proto' => 'ospf', 'dport' => ''],
'OpenVPN' => ['proto' => 'udp', 'dport' => '1194'],
'PCA' => ['proto' => 'tcp', 'dport' => '5631'],
'PMG' => ['proto' => 'tcp', 'dport' => '26'],
'POP3' => ['proto' => 'tcp', 'dport' => '110'],
'POP3S' => ['proto' => 'tcp', 'dport' => '995'],
'PPtP' => ['proto' => 'tcp', 'dport' => '1723'],
'Ping' => ['proto' => 'icmp', 'dport' => ''],
'PostgreSQL' => ['proto' => 'tcp', 'dport' => '5432'],
'Printer' => ['proto' => 'tcp', 'dport' => '515'],
'RDP' => ['proto' => 'tcp', 'dport' => '3389'],
'RIP' => ['proto' => 'udp', 'dport' => '520'],
'RNDC' => ['proto' => 'tcp', 'dport' => '953'],
'Razor' => ['proto' => 'tcp', 'dport' => '2703'],
'Rsyncd' => ['proto' => 'tcp', 'dport' => '873'],
'SANE' => ['proto' => 'tcp', 'dport' => '6566'],
'SMB' => ['proto' => 'tcp', 'dport' => '445'],
'SMTP' => ['proto' => 'tcp', 'dport' => '25'],
'SMTPS' => ['proto' => 'tcp', 'dport' => '465'],
'SNMP' => ['proto' => 'udp', 'dport' => '161,162'],
'SPAMD' => ['proto' => 'tcp', 'dport' => '783'],
'SSH' => ['proto' => 'tcp', 'dport' => '22'],
'SVN' => ['proto' => 'tcp', 'dport' => '3690'],
'SixXS' => ['proto' => 'tcp', 'dport' => '3874'],
'SpiceProxy' => ['proto' => 'tcp', 'dport' => '3128'],
'Squid' => ['proto' => 'tcp', 'dport' => '3128'],
'Submission' => ['proto' => 'tcp', 'dport' => '587'],
'Syslog' => ['proto' => 'udp', 'dport' => '514'],
'TFTP' => ['proto' => 'udp', 'dport' => '69'],
'Telnet' => ['proto' => 'tcp', 'dport' => '23'],
'Telnets' => ['proto' => 'tcp', 'dport' => '992'],
'Time' => ['proto' => 'tcp', 'dport' => '37'],
'Trcrt' => ['proto' => 'udp', 'dport' => '33434:33524'],
'VNC' => ['proto' => 'tcp', 'dport' => '5900:5909'],
'VNCL' => ['proto' => 'tcp', 'dport' => '5500:5509'],
'Web' => ['proto' => 'tcp', 'dport' => '80,443'],
'Webcache' => ['proto' => 'tcp', 'dport' => '3128,8080'],
'Webmin' => ['proto' => 'tcp', 'dport' => '10000'],
'Whois' => ['proto' => 'tcp', 'dport' => '43'],
];
// IP Aliases
public array $aliases = [];
public bool $showAliasForm = false;
public ?string $editingAliasName = null;
public string $aliasName = '';
public string $aliasCidr = '';
public string $aliasComment = '';
public bool $showDeleteAliasModal = false;
public ?string $deletingAliasName = null;
// IPSets
public array $ipsets = [];
public bool $showIPSetForm = false;
public ?string $editingIPSetName = null;
public string $ipsetName = '';
public string $ipsetComment = '';
public bool $showDeleteIPSetModal = false;
public ?string $deletingIPSetName = null;
// IPSet Members
public array $ipsetMembers = [];
public ?string $selectedIPSetName = null;
public bool $showIPSetMemberForm = false;
public string $memberCidr = '';
public string $memberComment = '';
public bool $memberNomatch = false;
public bool $showDeleteIPSetMemberModal = false;
public ?string $deletingMemberCidr = null;
// Firewall Logs
public array $firewallLogs = [];
public int $logLimit = 500; // Reduced to prevent 413 errors
public string $logError = '';
public bool $logsLoaded = false; // Track if logs have been loaded
// Pagination for logs
public int $currentPage = 1;
public int $perPage = 50;
public ?string $lastLogRefresh = null;
// Available IPs for rules
public array $availableIps = [];
public function mount(Server $server): void
{
parent::mount($server);
$this->server->loadMissing('ipAddresses');
$this->loadAvailableIps();
$this->loadNetworkAdapters();
$this->loadFirewallOptions();
$this->loadFirewallRules();
$this->loadAliases();
$this->loadIPSets();
// Don't load logs on mount - load lazily when logs tab is viewed
}
// ==================== AVAILABLE IPS ====================
protected function loadAvailableIps(): void
{
$allIps = collect();
if ($this->server->primaryIpv4) {
$allIps->push([
'ip' => $this->server->primaryIpv4->ip,
'type' => 'IPv4',
'label' => 'Primary IPv4'
]);
}
if ($this->server->primaryIpv6) {
$allIps->push([
'ip' => $this->server->primaryIpv6->ip,
'type' => 'IPv6',
'label' => 'Primary IPv6'
]);
}
foreach ($this->server->ipAddresses as $ipAddress) {
$allIps->push([
'ip' => $ipAddress->ip,
'type' => strtoupper($ipAddress->type),
'label' => 'Additional'
]);
}
$this->availableIps = $allIps->unique('ip')->values()->toArray();
}
// ==================== FIREWALL API REQUEST ====================
protected function firewallRequest(string $url, string $method = 'get', array $data = []): \Illuminate\Http\Client\Response
{
$baseUrl = 'https://pve.virtnet.bond';
$user = 'root@pam';
$tokenId = 'firewall';
$secret = '0a439967-0a44-4b94-8906-c36b3a40cf68';
$fullUrl = rtrim($baseUrl, '/') . '/api2/json' . $url;
$authHeader = 'PVEAPIToken=' . $user . '!' . $tokenId . '=' . $secret;
$acceptHeader = str_contains($url, '/firewall/log')
? 'application/json, text/plain, */*'
: 'application/json';
$headers = [
'Authorization' => $authHeader,
'Accept' => $acceptHeader,
];
// Prevent caching for log requests
if (str_contains($url, '/firewall/log')) {
$headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
$headers['Pragma'] = 'no-cache';
}
$request = Http::withHeaders($headers)->withoutVerifying();
$response = match($method) {
'post' => $request->post($fullUrl, $data),
'put' => $request->put($fullUrl, $data),
'delete' => $request->delete($fullUrl),
default => $request->get($fullUrl, $data),
};
return $response;
}
// ==================== NETWORK ADAPTERS ====================
protected function loadNetworkAdapters(): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config'
);
if ($response->successful()) {
$config = $response->json()['data'] ?? [];
$adapters = [];
foreach ($config as $key => $value) {
if (preg_match('/^net(\d+)$/', $key, $matches)) {
$parts = explode(',', $value);
$model = explode('=', $parts[0])[0] ?? 'unknown';
$mac = null;
// Store the full configuration string for later modification
$this->adapterConfigs['net' . $matches[1]] = $value;
foreach ($parts as $part) {
if (str_contains($part, '=')) {
[$k, $v] = explode('=', $part, 2);
if (trim($k) === 'firewall') {
$this->adapterFirewallStates['net' . $matches[1]] = (int)$v === 1;
} elseif (trim($k) === 'macaddr') {
$mac = $v;
}
}
}
$adapters[] = [
'name' => 'net' . $matches[1],
'model' => $model,
'mac' => $mac,
'enabled' => $this->adapterFirewallStates['net' . $matches[1]] ?? false
];
}
}
$this->networkAdapters = $adapters;
}
} catch (\Exception $e) {
Log::error('Failed to load network adapters: ' . $e->getMessage());
}
}
public function toggleAdapterFirewall(string $adapter): void
{
$this->loading = true;
try {
$currentState = $this->adapterFirewallStates[$adapter] ?? false;
$newState = !$currentState;
// Get the full network configuration string
$configString = $this->adapterConfigs[$adapter] ?? '';
if (empty($configString)) {
$this->error = 'Network adapter configuration not found';
$this->showErrorModal = true;
return;
}
// Parse the configuration string and modify the firewall parameter
$parts = explode(',', $configString);
$firewallFound = false;
foreach ($parts as $key => $part) {
$part = trim($part);
if (str_contains($part, '=')) {
[$k, $v] = explode('=', $part, 2);
if (trim($k) === 'firewall') {
$parts[$key] = 'firewall=' . ($newState ? '1' : '0');
$firewallFound = true;
break;
}
}
}
// If firewall parameter doesn't exist, add it
if (!$firewallFound) {
$parts[] = 'firewall=' . ($newState ? '1' : '0');
}
// Reconstruct the configuration string
$newConfigString = implode(',', $parts);
// Send the full modified configuration back to Proxmox
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
'put',
[$adapter => $newConfigString]
);
if ($response->successful()) {
$this->adapterFirewallStates[$adapter] = $newState;
$this->adapterConfigs[$adapter] = $newConfigString; // Update stored config
$this->notify('Adapter firewall ' . ($newState ? 'enabled' : 'disabled'), 'success');
} else {
$this->error = 'Failed to toggle adapter firewall: ' . $response->body();
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to toggle adapter firewall: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
// ==================== FIREWALL OPTIONS ====================
protected function loadFirewallOptions(): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options'
);
if ($response->successful()) {
$options = $response->json()['data'] ?? [];
$this->firewallEnabled = ($options['enable'] ?? 0) == 1;
$this->policyIn = strtoupper($options['policy_in'] ?? 'DROP');
$this->policyOut = strtoupper($options['policy_out'] ?? 'ACCEPT');
}
} catch (\Exception $e) {
Log::error('Failed to load firewall options: ' . $e->getMessage());
}
}
public function toggleFirewall(): void
{
$this->loading = true;
try {
$newState = !$this->firewallEnabled;
// Always force DHCP disabled and NDP enabled
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options',
'put',
[
'enable' => $newState ? 1 : 0,
'dhcp' => 0, // Always disabled
'ndp' => 1, // Always enabled (allows router advertisements)
]
);
if ($response->successful()) {
$this->firewallEnabled = $newState;
$this->notify('Firewall ' . ($newState ? 'enabled' : 'disabled'), 'success');
} else {
// Show detailed error message from API
$errorBody = $response->json();
$errorMsg = $errorBody['errors'] ?? $response->body();
$this->error = 'Failed to toggle firewall (Status ' . $response->status() . '): ' . (is_array($errorMsg) ? json_encode($errorMsg) : $errorMsg);
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to toggle firewall: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
public function updatePolicies(): void
{
$this->loading = true;
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options',
'put',
[
'policy_in' => $this->policyIn,
'policy_out' => $this->policyOut,
'dhcp' => 0, // Always disabled
'ndp' => 1, // Always enabled (allows router advertisements)
]
);
if ($response->successful()) {
$this->notify('Firewall policies updated successfully', 'success');
$this->loadFirewallOptions();
} else {
$this->error = 'Failed to update firewall policies';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to update policies: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
public function openPolicyInfoModal(): void
{
$this->showPolicyInfoModal = true;
}
public function closePolicyInfoModal(): void
{
$this->showPolicyInfoModal = false;
}
// ==================== RULES ====================
/**
* Get macro definitions for frontend use
*/
public function getMacroDefinitionsProperty(): array
{
return $this->macroDefinitions;
}
/**
* Get details for a specific macro
*/
public function getMacroDetails(string $macro): ?array
{
return $this->macroDefinitions[$macro] ?? null;
}
protected function loadFirewallRules(): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules'
);
if ($response->successful()) {
$this->rules = $response->json()['data'] ?? [];
}
} catch (\Exception $e) {
Log::error('Failed to load firewall rules: ' . $e->getMessage());
}
}
public function startCreateRule(): void
{
$this->resetRuleForm();
// Set default destination to first IP
if (!empty($this->availableIps)) {
$this->ruleDest = $this->availableIps[0]['ip'];
}
// Set default interface to first adapter
if (!empty($this->networkAdapters)) {
$this->ruleIface = $this->networkAdapters[0]['name'];
}
$this->editingRuleIndex = null;
$this->showRuleForm = true;
}
public function startEditRule(int $index): void
{
if (!isset($this->rules[$index])) {
$this->error = 'Rule not found';
$this->showErrorModal = true;
return;
}
$rule = $this->rules[$index];
$this->editingRuleIndex = $index;
$this->showRuleForm = true;
$this->ruleAction = $rule['action'] ?? 'ACCEPT';
$this->ruleType = $rule['type'] ?? 'in';
$this->ruleSource = $rule['source'] ?? '';
$this->ruleDest = $rule['dest'] ?? '';
$this->ruleProto = $rule['proto'] ?? '';
$this->ruleDport = $rule['dport'] ?? '';
$this->ruleSport = $rule['sport'] ?? '';
$this->ruleComment = $rule['comment'] ?? '';
$this->ruleEnable = !isset($rule['enable']) || $rule['enable'] != 0;
$this->ruleMacro = $rule['macro'] ?? '';
$this->ruleIface = $rule['iface'] ?? '';
$this->ruleLog = $rule['log'] ?? 'nolog';
}
public function saveRule(): void
{
$this->loading = true;
try {
$payload = [
'action' => $this->ruleAction,
'type' => $this->ruleType,
'enable' => $this->ruleEnable ? 1 : 0,
];
// Note: ruleMacro is only used for UI convenience to auto-populate fields
// It is NOT sent to Proxmox API - only the actual proto/dport values are sent
if (!empty(trim($this->ruleIface))) {
$payload['iface'] = trim($this->ruleIface);
}
if (!empty(trim($this->ruleSource))) {
$payload['source'] = trim($this->ruleSource);
}
if (!empty(trim($this->ruleDest))) {
$payload['dest'] = trim($this->ruleDest);
}
if (!empty(trim($this->ruleProto))) {
$payload['proto'] = trim($this->ruleProto);
}
if (!empty(trim($this->ruleDport))) {
$payload['dport'] = trim($this->ruleDport);
}
if (!empty(trim($this->ruleSport))) {
$payload['sport'] = trim($this->ruleSport);
}
if (!empty(trim($this->ruleComment))) {
$payload['comment'] = trim($this->ruleComment);
}
if (!empty(trim($this->ruleLog)) && $this->ruleLog !== 'nolog') {
$payload['log'] = $this->ruleLog;
}
if ($this->editingRuleIndex !== null && isset($this->rules[$this->editingRuleIndex])) {
$pos = $this->rules[$this->editingRuleIndex]['pos'];
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos,
'put',
$payload
);
} else {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules',
'post',
$payload
);
}
if ($response->successful()) {
$this->notify('Rule ' . ($this->editingRuleIndex !== null ? 'updated' : 'created') . ' successfully', 'success');
$this->resetRuleForm();
$this->editingRuleIndex = null;
$this->showRuleForm = false;
$this->loadFirewallRules();
} else {
$message = $response->json()['errors'][0]['message'] ?? 'Unknown error';
$this->error = 'Failed to save rule: ' . $message;
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to save rule: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
public function toggleRule(int $index): void
{
try {
if (!isset($this->rules[$index])) {
return;
}
$rule = $this->rules[$index];
$pos = $rule['pos'];
$currentEnable = isset($rule['enable']) ? (int)$rule['enable'] : 1;
$newEnable = $currentEnable == 0 ? 1 : 0;
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos,
'put',
['enable' => $newEnable]
);
if ($response->successful()) {
$this->notify('Rule ' . ($newEnable == 1 ? 'enabled' : 'disabled'), 'success');
$this->loadFirewallRules();
} else {
$this->error = 'Failed to toggle rule';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to toggle rule: ' . $e->getMessage();
$this->showErrorModal = true;
}
}
public function confirmDeleteRule(int $index): void
{
if (!isset($this->rules[$index])) {
return;
}
$this->deletingRuleIndex = $index;
$this->showDeleteRuleModal = true;
}
public function deleteRule(): void
{
if ($this->deletingRuleIndex === null || !isset($this->rules[$this->deletingRuleIndex])) {
return;
}
$rule = $this->rules[$this->deletingRuleIndex];
$pos = $rule['pos'];
$this->loading = true;
$this->showDeleteRuleModal = false;
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos,
'delete'
);
if ($response->successful()) {
$this->notify('Rule deleted successfully', 'success');
$this->loadFirewallRules();
} else {
$this->error = 'Failed to delete rule';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to delete rule: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
$this->deletingRuleIndex = null;
}
}
public function cancelDeleteRule(): void
{
$this->deletingRuleIndex = null;
$this->showDeleteRuleModal = false;
}
public function reorderRules(int $fromPos, int $toPos): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $fromPos,
'put',
['moveto' => $toPos]
);
if ($response->successful()) {
$this->notify('Rules reordered successfully', 'success');
$this->loadFirewallRules();
} else {
$this->error = 'Failed to reorder rules';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to reorder rules: ' . $e->getMessage();
$this->showErrorModal = true;
}
}
protected function resetRuleForm(): void
{
$this->ruleAction = 'ACCEPT';
$this->ruleType = 'in';
$this->ruleSource = '';
$this->ruleDest = '';
$this->ruleProto = '';
$this->ruleDport = '';
$this->ruleSport = '';
$this->ruleComment = '';
$this->ruleEnable = true;
$this->ruleMacro = '';
$this->ruleIface = '';
$this->ruleLog = 'nolog';
}
public function cancelRuleForm(): void
{
$this->resetRuleForm();
$this->editingRuleIndex = null;
$this->showRuleForm = false;
}
// ==================== ALIASES ====================
protected function loadAliases(): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases'
);
if ($response->successful()) {
$this->aliases = $response->json()['data'] ?? [];
}
} catch (\Exception $e) {
Log::error('Failed to load aliases: ' . $e->getMessage());
}
}
public function startCreateAlias(): void
{
$this->resetAliasForm();
$this->editingAliasName = null;
$this->showAliasForm = true;
}
public function startEditAlias(string $name): void
{
$alias = collect($this->aliases)->firstWhere('name', $name);
if ($alias) {
$this->editingAliasName = $name;
$this->aliasName = $alias['name'];
$this->aliasCidr = $alias['cidr'];
$this->aliasComment = $alias['comment'] ?? '';
$this->showAliasForm = true;
}
}
public function saveAlias(): void
{
$this->validate([
'aliasName' => 'required|string|max:64',
'aliasCidr' => 'required|string',
]);
$this->loading = true;
try {
$payload = [
'name' => trim($this->aliasName),
'cidr' => trim($this->aliasCidr),
];
if (!empty(trim($this->aliasComment))) {
$payload['comment'] = trim($this->aliasComment);
}
if ($this->editingAliasName) {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases/' . $this->editingAliasName,
'put',
$payload
);
} else {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases',
'post',
$payload
);
}
if ($response->successful()) {
$this->notify('Alias ' . ($this->editingAliasName ? 'updated' : 'created') . ' successfully', 'success');
$this->resetAliasForm();
$this->editingAliasName = null;
$this->showAliasForm = false;
$this->loadAliases();
} else {
$message = $response->json()['errors'][0]['message'] ?? 'Unknown error';
$this->error = 'Failed to save alias: ' . $message;
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to save alias: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
public function confirmDeleteAlias(string $name): void
{
$this->deletingAliasName = $name;
$this->showDeleteAliasModal = true;
}
public function deleteAlias(): void
{
if (!$this->deletingAliasName) {
return;
}
$this->loading = true;
$this->showDeleteAliasModal = false;
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases/' . $this->deletingAliasName,
'delete'
);
if ($response->successful()) {
$this->notify('Alias deleted successfully', 'success');
$this->loadAliases();
} else {
$this->error = 'Failed to delete alias';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to delete alias: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
$this->deletingAliasName = null;
}
}
public function cancelDeleteAlias(): void
{
$this->deletingAliasName = null;
$this->showDeleteAliasModal = false;
}
protected function resetAliasForm(): void
{
$this->aliasName = '';
$this->aliasCidr = '';
$this->aliasComment = '';
}
public function cancelAliasForm(): void
{
$this->resetAliasForm();
$this->editingAliasName = null;
$this->showAliasForm = false;
}
// ==================== IPSETS ====================
protected function loadIPSets(): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset'
);
if ($response->successful()) {
$this->ipsets = $response->json()['data'] ?? [];
}
} catch (\Exception $e) {
Log::error('Failed to load IPSets: ' . $e->getMessage());
}
}
public function startCreateIPSet(): void
{
$this->resetIPSetForm();
$this->editingIPSetName = null;
$this->showIPSetForm = true;
}
public function saveIPSet(): void
{
$this->validate([
'ipsetName' => 'required|string|max:64',
]);
$this->loading = true;
try {
$payload = [
'name' => trim($this->ipsetName),
];
if (!empty(trim($this->ipsetComment))) {
$payload['comment'] = trim($this->ipsetComment);
}
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset',
'post',
$payload
);
if ($response->successful()) {
$this->notify('IPSet created successfully', 'success');
$this->resetIPSetForm();
$this->showIPSetForm = false;
$this->loadIPSets();
} else {
$message = $response->json()['errors'][0]['message'] ?? 'Unknown error';
$this->error = 'Failed to create IPSet: ' . $message;
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to create IPSet: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
public function confirmDeleteIPSet(string $name): void
{
$this->deletingIPSetName = $name;
$this->showDeleteIPSetModal = true;
}
public function deleteIPSet(): void
{
if (!$this->deletingIPSetName) {
return;
}
$this->loading = true;
$this->showDeleteIPSetModal = false;
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->deletingIPSetName,
'delete'
);
if ($response->successful()) {
$this->notify('IPSet deleted successfully', 'success');
$this->loadIPSets();
// Clear selected IPSet if it was deleted
if ($this->selectedIPSetName === $this->deletingIPSetName) {
$this->selectedIPSetName = null;
$this->ipsetMembers = [];
}
} else {
$this->error = 'Failed to delete IPSet';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to delete IPSet: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
$this->deletingIPSetName = null;
}
}
public function cancelDeleteIPSet(): void
{
$this->deletingIPSetName = null;
$this->showDeleteIPSetModal = false;
}
protected function resetIPSetForm(): void
{
$this->ipsetName = '';
$this->ipsetComment = '';
}
public function cancelIPSetForm(): void
{
$this->resetIPSetForm();
$this->editingIPSetName = null;
$this->showIPSetForm = false;
}
// ==================== IPSET MEMBERS ====================
public function selectIPSet(string $name): void
{
$this->selectedIPSetName = $name;
$this->loadIPSetMembers($name);
}
protected function loadIPSetMembers(string $name): void
{
try {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $name
);
if ($response->successful()) {
$this->ipsetMembers = $response->json()['data'] ?? [];
}
} catch (\Exception $e) {
Log::error('Failed to load IPSet members: ' . $e->getMessage());
}
}
public function startAddIPSetMember(): void
{
$this->memberCidr = '';
$this->memberComment = '';
$this->memberNomatch = false;
$this->showIPSetMemberForm = true;
}
public function saveIPSetMember(): void
{
if (!$this->selectedIPSetName) {
return;
}
$this->validate([
'memberCidr' => 'required|string',
]);
$this->loading = true;
try {
$payload = [
'cidr' => trim($this->memberCidr),
];
if (!empty(trim($this->memberComment))) {
$payload['comment'] = trim($this->memberComment);
}
if ($this->memberNomatch) {
$payload['nomatch'] = 1;
}
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->selectedIPSetName,
'post',
$payload
);
if ($response->successful()) {
$this->notify('IP/CIDR added to IPSet successfully', 'success');
$this->memberCidr = '';
$this->memberComment = '';
$this->memberNomatch = false;
$this->showIPSetMemberForm = false;
$this->loadIPSetMembers($this->selectedIPSetName);
} else {
$message = $response->json()['errors'][0]['message'] ?? 'Unknown error';
$this->error = 'Failed to add member: ' . $message;
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to add member: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
}
}
public function confirmDeleteIPSetMember(string $cidr): void
{
$this->deletingMemberCidr = $cidr;
$this->showDeleteIPSetMemberModal = true;
}
public function deleteIPSetMember(): void
{
if (!$this->selectedIPSetName || !$this->deletingMemberCidr) {
return;
}
$this->loading = true;
$this->showDeleteIPSetMemberModal = false;
try {
// URL encode the CIDR for the DELETE request
$encodedCidr = urlencode($this->deletingMemberCidr);
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->selectedIPSetName . '/' . $encodedCidr,
'delete'
);
if ($response->successful()) {
$this->notify('IP/CIDR removed from IPSet successfully', 'success');
$this->loadIPSetMembers($this->selectedIPSetName);
} else {
$this->error = 'Failed to remove member';
$this->showErrorModal = true;
}
} catch (\Exception $e) {
$this->error = 'Failed to remove member: ' . $e->getMessage();
$this->showErrorModal = true;
} finally {
$this->loading = false;
$this->deletingMemberCidr = null;
}
}
public function cancelDeleteIPSetMember(): void
{
$this->deletingMemberCidr = null;
$this->showDeleteIPSetMemberModal = false;
}
public function cancelIPSetMemberForm(): void
{
$this->memberCidr = '';
$this->memberComment = '';
$this->memberNomatch = false;
$this->showIPSetMemberForm = false;
}
// ==================== FIREWALL LOGS ====================
public function loadFirewallLogs(): void
{
$this->logError = '';
try {
// Proxmox returns logs with newest first when using start=0
// Fetch a larger batch to ensure we get the most recent logs
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log',
'get',
[
'limit' => 5000, // Fetch more logs to get the recent ones
'start' => 0, // Start from the beginning (most recent)
'_dc' => time() // Cache buster
]
);
if (!$response->successful()) {
$this->logError = 'Failed to fetch logs (HTTP ' . $response->status() . ')';
Log::warning('Firewall log API request failed', [
'status' => $response->status(),
'server_id' => $this->server->id,
'vm_id' => $this->server->vm_id,
]);
$this->firewallLogs = [];
return;
}
// Proxmox firewall log endpoint can return JSON or text/plain
$body = $response->body();
$allRawLogs = [];
// Check content type
$contentType = $response->header('Content-Type');
$isJson = str_contains($contentType ?? '', 'json');
if ($isJson) {
try {
$jsonData = $response->json();
if (isset($jsonData['data'])) {
if (is_array($jsonData['data'])) {
foreach ($jsonData['data'] as $item) {
if (is_array($item) && isset($item['t'])) {
$allRawLogs[] = $item['t'];
} elseif (is_string($item)) {
$allRawLogs[] = $item;
}
}
} elseif (is_string($jsonData['data'])) {
$lines = preg_split('/\r\n|\r|\n/', $jsonData['data']);
$allRawLogs = array_filter($lines, fn($line) => !empty(trim($line)));
}
} elseif (is_array($jsonData)) {
foreach ($jsonData as $item) {
if (is_array($item) && isset($item['t'])) {
$allRawLogs[] = $item['t'];
} elseif (is_string($item)) {
$allRawLogs[] = $item;
}
}
}
} catch (\Exception $e) {
Log::warning('Failed to parse JSON response', ['error' => $e->getMessage()]);
}
}
// Fall back to text/plain format
if (empty($allRawLogs) && !empty($body)) {
$lines = preg_split('/\r\n|\r|\n/', $body);
$allRawLogs = array_filter($lines, fn($line) => !empty(trim($line)));
}
$allRawLogs = array_values($allRawLogs);
Log::info('Firewall logs fetched from Proxmox API', [
'raw_count' => count($allRawLogs),
'first_3_logs' => array_slice($allRawLogs, 0, 3)
]);
$this->firewallLogs = $this->parseFirewallLogs($allRawLogs);
// Only keep the most recent entries (limit to 500 for performance)
if (count($this->firewallLogs) > 500) {
$this->firewallLogs = array_slice($this->firewallLogs, 0, 500);
}
// Reset to page 1 when refreshing logs
$this->currentPage = 1;
// Mark logs as loaded
$this->logsLoaded = true;
// Set last refresh timestamp
$this->lastLogRefresh = now()->format('Y-m-d H:i:s');
// Notify user of successful refresh
$this->notify('Loaded ' . count($this->firewallLogs) . ' firewall log entries', 'success');
Log::info('Firewall log parsing summary', [
'total_raw_logs' => count($allRawLogs),
'successfully_parsed' => count($this->firewallLogs),
'last_refresh' => $this->lastLogRefresh,
'first_log_timestamp' => $this->firewallLogs[0]['timestamp'] ?? 'none',
'last_log_timestamp' => $this->firewallLogs[count($this->firewallLogs) - 1]['timestamp'] ?? 'none',
]);
} catch (\Exception $e) {
$this->logError = 'Failed to load logs: ' . $e->getMessage();
Log::error('Firewall logs error: ' . $e->getMessage());
$this->firewallLogs = [];
}
}
// ==================== LOG PAGINATION ====================
public function getPaginatedLogsProperty(): array
{
if (!$this->logsLoaded || empty($this->firewallLogs)) {
return [];
}
// Logs are already sorted by timestamp (most recent first) in parseFirewallLogs
$offset = ($this->currentPage - 1) * $this->perPage;
return array_slice($this->firewallLogs, $offset, $this->perPage);
}
public function getTotalPagesProperty(): int
{
if (!$this->logsLoaded || empty($this->firewallLogs)) {
return 1;
}
return (int)ceil(count($this->firewallLogs) / $this->perPage);
}
public function goToPage(int $page): void
{
$totalPages = $this->getTotalPagesProperty();
if ($page >= 1 && $page <= $totalPages) {
$this->currentPage = $page;
}
}
public function previousPage(): void
{
if ($this->currentPage > 1) {
$this->currentPage--;
}
}
public function nextPage(): void
{
if ($this->currentPage < $this->getTotalPagesProperty()) {
$this->currentPage++;
}
}
public function updatedPerPage(): void
{
// Reset to page 1 when changing per-page value
$this->currentPage = 1;
}
public function clearLogs(): void
{
// Clear logs to reduce payload size when not viewing logs tab
$this->firewallLogs = [];
$this->logsLoaded = false;
$this->currentPage = 1;
}
// ==================== LOG PARSING ====================
protected function parseFirewallLogs(array $rawLogs): array
{
$parsed = [];
foreach ($rawLogs as $logEntry) {
if (empty($logEntry) || !is_string($logEntry)) {
continue;
}
$log = $this->parseLogLine($logEntry);
if ($log) {
$parsed[] = $log;
}
}
// Sort logs by timestamp in descending order (most recent first)
usort($parsed, function($a, $b) {
$timeA = strtotime($a['timestamp_iso'] ?? $a['timestamp'] ?? '0');
$timeB = strtotime($b['timestamp_iso'] ?? $b['timestamp'] ?? '0');
return $timeB <=> $timeA; // Descending order
});
return $parsed;
}
protected function parseLogLine(string $line): ?array
{
// Parse Proxmox firewall log format
// Format: "vmid level chain timestamp action: KEY=VALUE ..."
if (!preg_match('/^(\d+)\s+(\d+)\s+(\S+)\s+([^\s]+\s+[^\s]+)\s+(\w+):\s*(.*)$/', $line, $matches)) {
return null;
}
$fields = [
'raw' => $line,
'vmid' => $matches[1],
'level' => (int)$matches[2],
'chain' => $matches[3],
'timestamp' => $matches[4],
'action' => $matches[5],
];
// Parse key-value pairs
$kvString = $matches[6];
if (preg_match_all('/(\w+)=([^\s]+)/', $kvString, $kvMatches, PREG_SET_ORDER)) {
foreach ($kvMatches as $match) {
$key = strtolower($match[1]);
$value = $match[2];
$fields[$key] = $value;
}
}
// Map fields to standardized names
$parsed = [
'raw' => $line,
'timestamp' => $fields['timestamp'] ?? '-',
'timestamp_iso' => $this->convertTimestamp($fields['timestamp'] ?? ''),
'timestamp_display' => $this->formatTimestampForDisplay($fields['timestamp'] ?? ''),
'action' => $fields['action'] ?? '-',
'level' => $fields['level'] ?? 0,
'level_label' => $this->getLevelLabel($fields['level'] ?? 0),
'level_badge_class' => $this->getLevelBadgeClass($fields['action'] ?? '-'),
'chain' => $fields['chain'] ?? '-',
'interface' => $fields['in'] ?? '-',
'source' => $fields['src'] ?? '-',
'destination' => $fields['dst'] ?? '-',
'source_port' => $fields['spt'] ?? '-',
'destination_port' => $fields['dpt'] ?? '-',
'protocol' => $fields['proto'] ?? '-',
'mac' => $fields['mac'] ?? '-',
];
// Debug log first entry to verify parsing
static $loggedOnce = false;
if (!$loggedOnce && count($fields) > 5) {
Log::info('Firewall log parse sample', [
'parsed_fields' => $fields,
'protocol_value' => $parsed['protocol'],
'source_value' => $parsed['source'],
]);
$loggedOnce = true;
}
return $parsed;
}
protected function convertTimestamp(string $timestamp): string
{
try {
$dt = Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp);
return $dt->toIso8601String();
} catch (\Exception $e) {
return $timestamp;
}
}
protected function formatTimestampForDisplay(string $timestamp): string
{
try {
// Parse the Proxmox timestamp format: 04/Dec/2025:06:49:07 -1000
$dt = Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp);
// Convert to user's timezone (using app timezone)
$dt->setTimezone(config('app.timezone', 'UTC'));
// Format as: Dec 04, 2025 16:49:07 (or use a more readable format)
return $dt->format('M d, Y H:i:s');
} catch (\Exception $e) {
return $timestamp;
}
}
protected function getLevelLabel(int $level): string
{
return match($level) {
0 => 'Emergency',
1 => 'Alert',
2 => 'Critical',
3 => 'Error',
4 => 'Warning',
5 => 'Notice',
6 => 'Info',
7 => 'Debug',
default => 'Unknown',
};
}
protected function getLevelBadgeClass(string $action): string
{
return match(strtoupper($action)) {
'ACCEPT' => 'bg-success/20 text-success',
'DROP' => 'bg-error/20 text-error',
'REJECT' => 'bg-warning/20 text-warning',
default => 'bg-base/20 text-base/60',
};
}
// ==================== IP LOCATION ====================
public function fetchIPLocation(string $ip): array
{
try {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return [
'status' => 'fail',
'message' => 'Invalid or private IP address'
];
}
$response = Http::timeout(5)->get("http://ip-api.com/json/{$ip}");
if ($response->successful()) {
$data = $response->json();
if (isset($data['status']) && $data['status'] === 'success') {
return $data;
}
return [
'status' => 'fail',
'message' => $data['message'] ?? 'Unable to fetch location data'
];
}
return [
'status' => 'fail',
'message' => 'Failed to fetch location data (HTTP ' . $response->status() . ')'
];
} catch (\Exception $e) {
return [
'status' => 'fail',
'message' => 'Error: ' . $e->getMessage()
];
}
}
// ==================== HELPER METHODS ====================
public function checkIsPublicIP(string $ip): bool
{
if (empty($ip) || $ip === '—' || $ip === '-') {
return false;
}
// Use PHP's filter_var with appropriate flags
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
// ==================== NETWORK ANALYSIS INTEGRATION ====================
public function getNetworkAnalysisForLog(
string $sourceIP,
string $destIP,
string $sourcePort,
string $destPort,
string $protocol
): array {
try {
// Check if Core Router is configured using the same logic as Statistics component
if (!$this->coreRouterEnabled()) {
return [
'connections' => [],
'error' => 'Core Router is not configured'
];
}
// Get network analysis from Statistics component logic
$client = \Illuminate\Support\Facades\App::make(CoreRouterClient::class);
// Fetch all firewall states
$allStates = $client->listStates();
// Log the search parameters for debugging
Log::info('Searching for network analysis match', [
'sourceIP' => $sourceIP,
'destIP' => $destIP,
'sourcePort' => $sourcePort,
'destPort' => $destPort,
'protocol' => $protocol,
'total_states' => count($allStates)
]);
// Filter states matching the log entry
$matchingConnections = [];
$debugSamples = [];
foreach ($allStates as $state) {
$rawSource = (string)($state['source'] ?? '');
$rawDestination = (string)($state['destination'] ?? '');
// Extract IPs from the state
$stateSourceIP = $this->extractIpFromField($rawSource);
$stateDestIP = $this->extractIpFromField($rawDestination);
// Store first 5 for debugging
if (count($debugSamples) < 5) {
$debugSamples[] = [
'raw_source' => $rawSource,
'raw_destination' => $rawDestination,
'extracted_source' => $stateSourceIP,
'extracted_dest' => $stateDestIP,
];
}
// Extract ports
$stateSourcePort = null;
$stateDestPort = null;
if (preg_match('/\[(\d+)\]$/', $rawSource, $matches)) {
$stateSourcePort = (int)$matches[1];
} elseif (preg_match('/:(\d+)$/', $rawSource, $matches) && filter_var(explode(':', $rawSource)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$stateSourcePort = (int)$matches[1];
}
if (preg_match('/\[(\d+)\]$/', $rawDestination, $matches)) {
$stateDestPort = (int)$matches[1];
} elseif (preg_match('/:(\d+)$/', $rawDestination, $matches) && filter_var(explode(':', $rawDestination)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$stateDestPort = (int)$matches[1];
}
$stateProtocol = strtoupper($state['protocol'] ?? '');
// Normalize all values for comparison
$normalizedSourceIP = strtolower(trim($sourceIP));
$normalizedDestIP = strtolower(trim($destIP));
$normalizedStateSourceIP = strtolower(trim($stateSourceIP));
$normalizedStateDestIP = strtolower(trim($stateDestIP));
$normalizedProtocol = strtoupper(trim($protocol));
// Matching logic - Source IP from log MUST always match
// The source IP can appear as either source or destination in the state (for return traffic)
$ipMatch = false;
$matchType = '';
// Check if the log's source IP appears in the state
$sourceInState = ($normalizedStateSourceIP === $normalizedSourceIP || $normalizedStateDestIP === $normalizedSourceIP);
if (!$sourceInState) {
// Source IP not found in this state - skip it
continue;
}
// Source IP matches, now check destination
// Level 1: Exact match - both IPs match (in either direction)
if ($normalizedStateSourceIP === $normalizedSourceIP && $normalizedStateDestIP === $normalizedDestIP) {
$ipMatch = true;
$matchType = 'exact';
}
elseif ($normalizedStateSourceIP === $normalizedDestIP && $normalizedStateDestIP === $normalizedSourceIP) {
$ipMatch = true;
$matchType = 'exact-reversed';
}
// Level 2: Source IP matches, destination is flexible (shows all traffic from this source)
elseif ($sourceInState) {
$ipMatch = true;
$matchType = 'source-match';
}
// Protocol match - be flexible
$protocolMatch = (empty($protocol) || $protocol === '-' || $normalizedProtocol === $stateProtocol);
// Port match - check if destination port from log matches any port in state
$portMatch = false;
if (empty($destPort) || $destPort === '-') {
$portMatch = true; // No port specified, match all
} else {
$destPortNum = (int)$destPort;
if ($stateDestPort === $destPortNum || $stateSourcePort === $destPortNum) {
$portMatch = true;
}
// For source-match type, be more flexible on ports if protocol matches
if (!$portMatch && $matchType === 'source-match' && $protocolMatch) {
$portMatch = true;
}
}
// Log first potential match for debugging
if ($ipMatch && $protocolMatch && $portMatch && count($matchingConnections) === 0) {
Log::info('First match found', [
'match_type' => $matchType,
'state_source' => $stateSourceIP,
'state_dest' => $stateDestIP,
'state_protocol' => $stateProtocol,
'state_src_port' => $stateSourcePort,
'state_dst_port' => $stateDestPort,
'log_source' => $sourceIP,
'log_dest' => $destIP,
'log_protocol' => $protocol,
'log_dest_port' => $destPort,
]);
}
if ($ipMatch && $protocolMatch && $portMatch) {
$bytes = (int)($state['bytes_total'] ?? $state['bytes'] ?? 0);
$packets = (int)($state['packets_total'] ?? $state['packets'] ?? 0);
$stateName = strtoupper($state['state'] ?? 'UNKNOWN');
$interface = strtoupper($state['interface'] ?? 'N/A');
$direction = strtolower($state['direction'] ?? '') === 'in' ? 'Inbound' : 'Outbound';
$matchingConnections[] = [
'protocol' => $stateProtocol,
'source' => $stateSourceIP,
'destination' => $stateDestIP,
'source_port' => $stateSourcePort,
'destination_port' => $stateDestPort,
'raw_source' => $rawSource,
'raw_destination' => $rawDestination,
'bytes' => $bytes,
'bytes_human' => $this->formatBytes($bytes),
'packets' => $packets,
'state' => $stateName,
'interface' => $interface,
'direction' => $direction,
];
}
}
// Log results for debugging
Log::info('Network analysis match results', [
'matches_found' => count($matchingConnections),
'debug_samples' => $debugSamples
]);
// Sort by bytes (highest first)
usort($matchingConnections, fn($a, $b) => $b['bytes'] <=> $a['bytes']);
return [
'connections' => $matchingConnections,
'error' => null
];
} catch (\Exception $e) {
Log::error('Failed to fetch network analysis for log entry', [
'error' => $e->getMessage(),
'source' => $sourceIP,
'dest' => $destIP
]);
return [
'connections' => [],
'error' => 'Failed to fetch network data: ' . $e->getMessage()
];
}
}
protected function coreRouterEnabled(): bool
{
$baseUrl = config('corerouter.base_url');
$apiKey = config('corerouter.api_key');
$username = config('corerouter.username');
$password = config('corerouter.password');
// Check if base_url is configured and not empty
$hasBaseUrl = !empty(trim((string) $baseUrl));
// Check if API key is configured (not empty after trimming)
$hasKey = !empty(trim((string) $apiKey));
// Check if basic auth credentials are both configured
$hasBasic = !empty(trim((string) $username)) && !empty(trim((string) $password));
// Configuration is valid if base_url is set and at least one auth method is configured
return $hasBaseUrl && ($hasKey || $hasBasic);
}
protected function extractIpFromField(string $field): string
{
if (empty($field)) {
return '';
}
$field = trim($field);
// Handle IPv4 "IP:port" format FIRST
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) {
return $matches[1];
}
// Handle "IP[port]" format
if (preg_match('/^([^\[\]]+)(?:\[[^\]]+\])?$/', $field, $matches)) {
$ip = trim($matches[1]);
$ip = rtrim($ip, ':[]');
return $ip;
}
return $field;
}
protected function formatBytes(int $bytes): string
{
if ($bytes === 0) {
return '0 B';
}
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
$power = floor(log($bytes, 1024));
$power = min($power, count($units) - 1);
return round($bytes / pow(1024, $power), 2) . ' ' . $units[$power];
}
public function render()
{
return view('proxmox::firewall')->layoutData([
'sidebar' => true,
]);
}
}