'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; // Core Router integration for network analysis public bool $coreRouterConfigured = false; // Available IPs for rules public array $availableIps = []; public function mount(Server $server): void { parent::mount($server); $this->server->loadMissing('ipAddresses'); $this->coreRouterConfigured = $this->coreRouterEnabled(); $this->loadAvailableIps(); $this->loadNetworkAdapters(); $this->loadFirewallOptions(); $this->loadFirewallRules(); $this->loadAliases(); $this->loadIPSets(); $this->loadSavedConfigurations(); // 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 { $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; } // Optimistic UI update - update state immediately $this->adapterFirewallStates[$adapter] = $newState; $this->skipRender(); try { // 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->adapterConfigs[$adapter] = $newConfigString; // Update stored config $this->dispatch('notify', 'Adapter firewall ' . ($newState ? 'enabled' : 'disabled'), 'success'); } else { // Revert on failure $this->adapterFirewallStates[$adapter] = $currentState; $this->error = 'Failed to toggle adapter firewall: ' . $response->body(); $this->showErrorModal = true; } } catch (\Exception $e) { // Revert on failure $this->adapterFirewallStates[$adapter] = $currentState; $this->error = 'Failed to toggle adapter firewall: ' . $e->getMessage(); $this->showErrorModal = true; } } // ==================== 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 { $newState = !$this->firewallEnabled; // Optimistic UI update - update state immediately $this->firewallEnabled = $newState; $this->skipRender(); try { // 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->dispatch('notify', 'Firewall ' . ($newState ? 'enabled' : 'disabled'), 'success'); } else { // Revert on failure $this->firewallEnabled = !$newState; // 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) { // Revert on failure $this->firewallEnabled = !$newState; $this->error = 'Failed to toggle firewall: ' . $e->getMessage(); $this->showErrorModal = true; } } 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->dispatch('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 { // Build the payload according to Proxmox API requirements $payload = [ 'action' => strtoupper($this->ruleAction), // Ensure uppercase (ACCEPT, REJECT, DROP) 'type' => $this->ruleType, // 'in' or 'out' 'enable' => $this->ruleEnable ? 1 : 0, ]; // Add optional fields only if they have values // Proxmox API is strict about empty values if (!empty($this->ruleIface)) { $payload['iface'] = trim($this->ruleIface); } if (!empty($this->ruleSource)) { $payload['source'] = trim($this->ruleSource); } if (!empty($this->ruleDest)) { $payload['dest'] = trim($this->ruleDest); } // Protocol must be lowercase for Proxmox API if (!empty($this->ruleProto)) { $payload['proto'] = strtolower(trim($this->ruleProto)); } if (!empty($this->ruleDport)) { $payload['dport'] = trim($this->ruleDport); } if (!empty($this->ruleSport)) { $payload['sport'] = trim($this->ruleSport); } if (!empty($this->ruleComment)) { $payload['comment'] = trim($this->ruleComment); } if (!empty($this->ruleLog) && $this->ruleLog !== 'nolog') { $payload['log'] = $this->ruleLog; } // Determine if we're editing an existing rule or creating a new one if ($this->editingRuleIndex !== null && isset($this->rules[$this->editingRuleIndex])) { // EDIT EXISTING RULE $pos = $this->rules[$this->editingRuleIndex]['pos']; $url = '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos; Log::info('Updating firewall rule', [ 'url' => $url, 'payload' => $payload, 'pos' => $pos ]); $response = $this->firewallRequest($url, 'put', $payload); } else { // CREATE NEW RULE $url = '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules'; Log::info('Creating firewall rule', [ 'url' => $url, 'payload' => $payload ]); $response = $this->firewallRequest($url, 'post', $payload); } // Log the response for debugging Log::info('Firewall rule API response', [ 'status' => $response->status(), 'successful' => $response->successful(), 'body' => $response->body() ]); if ($response->successful()) { $this->dispatch('notify', 'Rule ' . ($this->editingRuleIndex !== null ? 'updated' : 'created') . ' successfully', 'success'); // Reset the form $this->resetRuleForm(); $this->editingRuleIndex = null; $this->showRuleForm = false; // Reload rules from Proxmox to get the updated list $this->loadFirewallRules(); } else { // Extract error message from Proxmox API response $responseData = $response->json(); $message = $responseData['errors'][0]['message'] ?? $responseData['message'] ?? $response->body(); Log::error('Failed to save firewall rule', [ 'status' => $response->status(), 'response' => $responseData, 'payload' => $payload ]); $this->error = 'Failed to save rule: ' . $message; $this->showErrorModal = true; } } catch (\Exception $e) { Log::error('Exception saving firewall rule', [ 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); $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->dispatch('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->dispatch('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->dispatch('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->dispatch('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->dispatch('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->dispatch('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->dispatch('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->dispatch('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->dispatch('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->dispatch('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 ==================== // Cache for firewall states to avoid repeated API calls protected ?array $cachedStates = null; protected ?int $cacheTimestamp = null; protected const STATE_CACHE_TTL = 30; // Cache states for 30 seconds public function getNetworkAnalysisForLog( string $sourceIP, string $destIP, string $sourcePort, string $destPort, string $protocol ): array { try { // Check if Core Router is configured if (!$this->coreRouterEnabled()) { return [ 'connections' => [], 'error' => 'Core Router is not configured' ]; } $client = \Illuminate\Support\Facades\App::make(CoreRouterClient::class); // Use cached states if available and not expired $now = time(); if ($this->cachedStates === null || $this->cacheTimestamp === null || ($now - $this->cacheTimestamp) > self::STATE_CACHE_TTL) { $this->cachedStates = $client->listStates() ?? []; $this->cacheTimestamp = $now; } $allStates = $this->cachedStates ?? []; // Normalize search parameters once $normalizedSourceIP = strtolower(trim($sourceIP)); $normalizedDestIP = strtolower(trim($destIP)); $normalizedProtocol = strtoupper(trim($protocol)); $destPortNum = ($destPort && $destPort !== '-') ? (int)$destPort : null; // Filter states matching the log entry $matchingConnections = []; $maxMatches = 50; // Limit results for performance foreach ($allStates as $state) { if (count($matchingConnections) >= $maxMatches) { break; } $rawSource = (string)($state['source'] ?? ''); $rawDestination = (string)($state['destination'] ?? ''); // Quick check - skip if source IP not present in either field if (strpos(strtolower($rawSource), $normalizedSourceIP) === false && strpos(strtolower($rawDestination), $normalizedSourceIP) === false) { continue; } // Extract IPs from the state $stateSourceIP = $this->extractIpFromFieldFast($rawSource); $stateDestIP = $this->extractIpFromFieldFast($rawDestination); $normalizedStateSourceIP = strtolower($stateSourceIP); $normalizedStateDestIP = strtolower($stateDestIP); // Check if the log's source IP appears in the state $sourceInState = ($normalizedStateSourceIP === $normalizedSourceIP || $normalizedStateDestIP === $normalizedSourceIP); if (!$sourceInState) { continue; } // Protocol match $stateProtocol = strtoupper($state['protocol'] ?? ''); if (!empty($protocol) && $protocol !== '-' && $normalizedProtocol !== $stateProtocol) { continue; } // Port match if specified if ($destPortNum !== null) { $stateSourcePort = $this->extractPortFromField($rawSource); $stateDestPort = $this->extractPortFromField($rawDestination); if ($stateDestPort !== $destPortNum && $stateSourcePort !== $destPortNum) { continue; } } else { $stateSourcePort = $this->extractPortFromField($rawSource); $stateDestPort = $this->extractPortFromField($rawDestination); } // Match found - build connection data $bytes = (int)($state['bytes_total'] ?? $state['bytes'] ?? 0); $packets = (int)($state['packets_total'] ?? $state['packets'] ?? 0); $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' => strtoupper($state['state'] ?? 'UNKNOWN'), 'interface' => strtoupper($state['interface'] ?? 'N/A'), 'direction' => strtolower($state['direction'] ?? '') === 'in' ? 'Inbound' : 'Outbound', ]; } // 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, ]); return [ 'connections' => [], 'error' => 'Failed to fetch network data: ' . $e->getMessage() ]; } } /** * Fast IP extraction without regex for common formats */ protected function extractIpFromFieldFast(string $field): string { if (empty($field)) { return ''; } $field = trim($field); // Handle IPv4 "IP:port" format - most common $colonPos = strrpos($field, ':'); if ($colonPos !== false) { $potentialIp = substr($field, 0, $colonPos); // Check if it's a valid IPv4 if (filter_var($potentialIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return $potentialIp; } } // Handle "IP[port]" format $bracketPos = strpos($field, '['); if ($bracketPos !== false) { return substr($field, 0, $bracketPos); } // Return as-is if it's a valid IP if (filter_var($field, FILTER_VALIDATE_IP)) { return $field; } return $field; } /** * Fast port extraction */ protected function extractPortFromField(string $field): ?int { if (empty($field)) { return null; } // Handle "[port]" format if (preg_match('/\[(\d+)\]$/', $field, $matches)) { return (int)$matches[1]; } // Handle "IP:port" format for IPv4 $colonPos = strrpos($field, ':'); if ($colonPos !== false) { $potentialIp = substr($field, 0, $colonPos); if (filter_var($potentialIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return (int)substr($field, $colonPos + 1); } } return null; } 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 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]; } // ==================== SAVED CONFIGURATIONS ==================== /** * Load all saved firewall configurations for the current user. */ protected function loadSavedConfigurations(): void { $userId = Auth::id(); if (!$userId) { $this->savedConfigurations = []; return; } $this->savedConfigurations = FirewallConfiguration::forUser($userId) ->orderBy('created_at', 'desc') ->get() ->toArray(); } /** * Open the save configuration modal. */ public function openSaveConfigModal(): void { $this->newConfigName = ''; $this->showSaveConfigModal = true; } /** * Close the save configuration modal. */ public function closeSaveConfigModal(): void { $this->newConfigName = ''; $this->showSaveConfigModal = false; } /** * Save the current firewall configuration. */ public function saveConfiguration(): void { $this->validate([ 'newConfigName' => 'required|string|min:1|max:255', ]); $userId = Auth::id(); if (!$userId) { $this->error = 'You must be logged in to save configurations'; $this->showErrorModal = true; return; } $this->loading = true; try { // Capture the current firewall state $configData = [ 'rules' => $this->rules, 'policyIn' => $this->policyIn, 'policyOut' => $this->policyOut, ]; FirewallConfiguration::create([ 'user_id' => $userId, 'configuration_name' => trim($this->newConfigName), 'firewall_rules' => $configData['rules'], 'incoming_policy' => $configData['policyIn'], 'outgoing_policy' => $configData['policyOut'], ]); $this->dispatch('notify', 'Configuration saved successfully', 'success'); $this->closeSaveConfigModal(); $this->loadSavedConfigurations(); } catch (\Exception $e) { Log::error('Failed to save firewall configuration: ' . $e->getMessage()); $this->error = 'Failed to save configuration: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; } } /** * Open the view configuration modal. */ public function viewConfiguration(int $configId): void { $userId = Auth::id(); if (!$userId) { return; } $config = FirewallConfiguration::forUser($userId)->find($configId); if (!$config) { $this->error = 'Configuration not found or access denied'; $this->showErrorModal = true; return; } $this->viewingConfiguration = $config; $this->configCompatibilityIssues = $this->checkConfigurationCompatibility($config); $this->showViewConfigModal = true; } /** * Close the view configuration modal. */ public function closeViewConfigModal(): void { $this->viewingConfiguration = null; $this->configCompatibilityIssues = []; $this->showViewConfigModal = false; } /** * Check configuration compatibility with the current VPS. */ protected function checkConfigurationCompatibility(FirewallConfiguration $config): array { $issues = []; $savedRules = $config->firewall_rules; if (!is_array($savedRules)) { return $issues; } // Get current available interfaces $availableInterfaces = collect($this->networkAdapters)->pluck('name')->toArray(); foreach ($savedRules as $index => $rule) { $ruleIssues = []; // Check if interface exists on current VPS if (!empty($rule['iface']) && !in_array($rule['iface'], $availableInterfaces)) { $ruleIssues[] = "Interface '{$rule['iface']}' does not exist on this VPS"; } // Check if source IP is available (if it references a specific IP) if (!empty($rule['source'])) { $sourceIp = $rule['source']; // Skip validation for CIDR ranges, aliases, or "any" if (!str_contains($sourceIp, '/') && !str_starts_with($sourceIp, '+') && $sourceIp !== 'any' && filter_var($sourceIp, FILTER_VALIDATE_IP)) { // This is a specific IP, check if it's one of our IPs $ourIps = collect($this->availableIps)->pluck('ip')->toArray(); if (!in_array($sourceIp, $ourIps)) { // Not one of our IPs, which is fine for source (external IPs) } } } // Check if destination IP is available if (!empty($rule['dest'])) { $destIp = $rule['dest']; // Skip validation for CIDR ranges, aliases, or "any" if (!str_contains($destIp, '/') && !str_starts_with($destIp, '+') && $destIp !== 'any' && filter_var($destIp, FILTER_VALIDATE_IP)) { // This is a specific IP, check if it's one of our IPs $ourIps = collect($this->availableIps)->pluck('ip')->toArray(); if (!in_array($destIp, $ourIps)) { $ruleIssues[] = "Destination IP '{$destIp}' is not assigned to this VPS"; } } } if (!empty($ruleIssues)) { $issues[] = [ 'rule_index' => $index, 'rule' => $rule, 'issues' => $ruleIssues, ]; } } return $issues; } /** * Open the confirm apply configuration modal. */ public function confirmApplyConfiguration(int $configId): void { $userId = Auth::id(); if (!$userId) { return; } $config = FirewallConfiguration::forUser($userId)->find($configId); if (!$config) { $this->error = 'Configuration not found or access denied'; $this->showErrorModal = true; return; } $this->applyingConfiguration = $config; $this->applyCompatibilityWarnings = $this->checkConfigurationCompatibility($config); $this->showApplyConfigModal = true; } /** * Close the apply configuration modal. */ public function closeApplyConfigModal(): void { $this->applyingConfiguration = null; $this->applyCompatibilityWarnings = []; $this->showApplyConfigModal = false; } /** * Apply a saved configuration to the current VPS. */ public function applyConfiguration(): void { if (!$this->applyingConfiguration) { return; } $userId = Auth::id(); if (!$userId) { $this->error = 'You must be logged in to apply configurations'; $this->showErrorModal = true; return; } // Verify ownership if ($this->applyingConfiguration->user_id !== $userId) { $this->error = 'Access denied'; $this->showErrorModal = true; return; } $this->loading = true; $incompatibleRulesDisabled = 0; try { $config = $this->applyingConfiguration; $savedRules = $config->firewall_rules; $availableInterfaces = collect($this->networkAdapters)->pluck('name')->toArray(); $ourIps = collect($this->availableIps)->pluck('ip')->toArray(); // Step 1: Update policies $this->policyIn = $config->incoming_policy; $this->policyOut = $config->outgoing_policy; $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, 'ndp' => 1, ] ); if (!$response->successful()) { throw new \Exception('Failed to update firewall policies'); } // Step 2: Delete all existing rules foreach (array_reverse($this->rules) as $rule) { if (isset($rule['pos'])) { $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $rule['pos'], 'delete' ); } } // Step 3: Create new rules from saved configuration if (is_array($savedRules)) { foreach ($savedRules as $savedRule) { $isCompatible = true; $rulePayload = []; // Check interface compatibility if (!empty($savedRule['iface']) && !in_array($savedRule['iface'], $availableInterfaces)) { $isCompatible = false; } // Check destination IP compatibility if (!empty($savedRule['dest'])) { $destIp = $savedRule['dest']; if (!str_contains($destIp, '/') && !str_starts_with($destIp, '+') && $destIp !== 'any' && filter_var($destIp, FILTER_VALIDATE_IP) && !in_array($destIp, $ourIps)) { $isCompatible = false; } } // Build the rule payload $rulePayload['action'] = $savedRule['action'] ?? 'ACCEPT'; $rulePayload['type'] = $savedRule['type'] ?? 'in'; // If incompatible, disable the rule if (!$isCompatible) { $rulePayload['enable'] = 0; $incompatibleRulesDisabled++; } else { $rulePayload['enable'] = isset($savedRule['enable']) ? (int)$savedRule['enable'] : 1; } // Add optional fields if (!empty($savedRule['iface']) && in_array($savedRule['iface'], $availableInterfaces)) { $rulePayload['iface'] = $savedRule['iface']; } if (!empty($savedRule['source'])) { $rulePayload['source'] = $savedRule['source']; } if (!empty($savedRule['dest']) && $isCompatible) { $rulePayload['dest'] = $savedRule['dest']; } if (!empty($savedRule['proto'])) { $rulePayload['proto'] = $savedRule['proto']; } if (!empty($savedRule['dport'])) { $rulePayload['dport'] = $savedRule['dport']; } if (!empty($savedRule['sport'])) { $rulePayload['sport'] = $savedRule['sport']; } if (!empty($savedRule['comment'])) { $rulePayload['comment'] = $savedRule['comment']; } if (!empty($savedRule['log']) && $savedRule['log'] !== 'nolog') { $rulePayload['log'] = $savedRule['log']; } // Create the rule $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules', 'post', $rulePayload ); } } // Reload the current state $this->loadFirewallOptions(); $this->loadFirewallRules(); $message = 'Configuration applied successfully'; if ($incompatibleRulesDisabled > 0) { $message .= ". {$incompatibleRulesDisabled} rule(s) were disabled due to incompatibility with this VPS."; $this->dispatch('notify', $message, 'warning'); } else { $this->dispatch('notify', $message, 'success'); } $this->closeApplyConfigModal(); } catch (\Exception $e) { Log::error('Failed to apply firewall configuration: ' . $e->getMessage()); $this->error = 'Failed to apply configuration: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; } } /** * Confirm deletion of a saved configuration. */ public function confirmDeleteConfiguration(int $configId): void { $this->deletingConfigId = $configId; $this->showDeleteConfigModal = true; } /** * Cancel deletion of a saved configuration. */ public function cancelDeleteConfiguration(): void { $this->deletingConfigId = null; $this->showDeleteConfigModal = false; } /** * Delete a saved configuration. */ public function deleteConfiguration(): void { if (!$this->deletingConfigId) { return; } $userId = Auth::id(); if (!$userId) { $this->error = 'You must be logged in to delete configurations'; $this->showErrorModal = true; return; } $this->loading = true; try { $config = FirewallConfiguration::forUser($userId)->find($this->deletingConfigId); if (!$config) { $this->error = 'Configuration not found or access denied'; $this->showErrorModal = true; return; } $config->delete(); $this->dispatch('notify', 'Configuration deleted successfully', 'success'); $this->cancelDeleteConfiguration(); $this->loadSavedConfigurations(); } catch (\Exception $e) { Log::error('Failed to delete firewall configuration: ' . $e->getMessage()); $this->error = 'Failed to delete configuration: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; } } /** * Open the rename configuration modal. */ public function openRenameConfigModal(int $configId): void { $userId = Auth::id(); if (!$userId) { return; } $config = FirewallConfiguration::forUser($userId)->find($configId); if (!$config) { $this->error = 'Configuration not found or access denied'; $this->showErrorModal = true; return; } $this->renamingConfigId = $configId; $this->renameConfigName = $config->configuration_name; $this->showRenameConfigModal = true; } /** * Close the rename configuration modal. */ public function closeRenameConfigModal(): void { $this->renamingConfigId = null; $this->renameConfigName = ''; $this->showRenameConfigModal = false; } /** * Rename a saved configuration. */ public function renameConfiguration(): void { if (!$this->renamingConfigId) { return; } $this->validate([ 'renameConfigName' => 'required|string|min:1|max:255', ]); $userId = Auth::id(); if (!$userId) { $this->error = 'You must be logged in to rename configurations'; $this->showErrorModal = true; return; } $this->loading = true; try { $config = FirewallConfiguration::forUser($userId)->find($this->renamingConfigId); if (!$config) { $this->error = 'Configuration not found or access denied'; $this->showErrorModal = true; return; } $config->update([ 'configuration_name' => trim($this->renameConfigName), ]); $this->dispatch('notify', 'Configuration renamed successfully', 'success'); $this->closeRenameConfigModal(); $this->loadSavedConfigurations(); } catch (\Exception $e) { Log::error('Failed to rename firewall configuration: ' . $e->getMessage()); $this->error = 'Failed to rename configuration: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; } } // ==================== APPLY TO ALL SERVERS ==================== /** * Get the current configuration as a normalized array for comparison. */ protected function getCurrentConfigurationData(): array { return [ 'rules' => $this->rules, 'policyIn' => $this->policyIn, 'policyOut' => $this->policyOut, ]; } /** * Check if the current configuration matches a saved configuration. */ public function currentConfigMatchesSaved(): bool { $currentConfig = $this->getCurrentConfigurationData(); foreach ($this->savedConfigurations as $saved) { if ($this->configurationsMatch($currentConfig, [ 'rules' => $saved['firewall_rules'] ?? [], 'policyIn' => $saved['incoming_policy'] ?? 'DROP', 'policyOut' => $saved['outgoing_policy'] ?? 'ACCEPT', ])) { return true; } } return false; } /** * Get the saved configuration that matches the current configuration, if any. */ public function getMatchingSavedConfiguration(): ?array { $currentConfig = $this->getCurrentConfigurationData(); foreach ($this->savedConfigurations as $saved) { if ($this->configurationsMatch($currentConfig, [ 'rules' => $saved['firewall_rules'] ?? [], 'policyIn' => $saved['incoming_policy'] ?? 'DROP', 'policyOut' => $saved['outgoing_policy'] ?? 'ACCEPT', ])) { return $saved; } } return null; } /** * Compare two configurations to see if they match. */ protected function configurationsMatch(array $config1, array $config2): bool { // Compare policies if ($config1['policyIn'] !== $config2['policyIn'] || $config1['policyOut'] !== $config2['policyOut']) { return false; } // Compare rules count $rules1 = $config1['rules'] ?? []; $rules2 = $config2['rules'] ?? []; if (count($rules1) !== count($rules2)) { return false; } // Compare each rule (ignoring position) $normalizedRules1 = $this->normalizeRulesForComparison($rules1); $normalizedRules2 = $this->normalizeRulesForComparison($rules2); return $normalizedRules1 === $normalizedRules2; } /** * Normalize rules for comparison by removing position-specific data. */ protected function normalizeRulesForComparison(array $rules): string { $normalized = []; foreach ($rules as $rule) { $normalizedRule = [ 'type' => $rule['type'] ?? '', 'action' => $rule['action'] ?? '', 'enable' => isset($rule['enable']) ? (int)$rule['enable'] : 1, 'source' => $rule['source'] ?? '', 'dest' => $rule['dest'] ?? '', 'proto' => $rule['proto'] ?? '', 'dport' => $rule['dport'] ?? '', 'sport' => $rule['sport'] ?? '', 'macro' => $rule['macro'] ?? '', 'iface' => $rule['iface'] ?? '', 'log' => $rule['log'] ?? 'nolog', 'comment' => $rule['comment'] ?? '', ]; ksort($normalizedRule); $normalized[] = $normalizedRule; } // Sort rules by their content to ensure order doesn't matter usort($normalized, function($a, $b) { return json_encode($a) <=> json_encode($b); }); return json_encode($normalized); } /** * Get all other servers owned by the current user. */ public function getUserOtherServers(): array { $userId = Auth::id(); if (!$userId) { return []; } return Server::where('user_id', $userId) ->where('id', '!=', $this->server->id) ->with(['node', 'primaryIpv4', 'primaryIpv6']) ->get() ->toArray(); } /** * Check if Apply to All should be enabled. * Returns true if current config is saved AND user has other servers with different configs. */ public function canApplyToAll(): bool { // Must have a saved configuration that matches current if (!$this->currentConfigMatchesSaved()) { return false; } // Must have other servers $otherServers = $this->getUserOtherServers(); if (count($otherServers) === 0) { return false; } return true; } /** * Open the Apply to All modal. */ public function openApplyToAllModal(): void { $this->applyToAllResults = []; $this->showApplyToAllModal = true; } /** * Close the Apply to All modal. */ public function closeApplyToAllModal(): void { $this->applyToAllResults = []; $this->showApplyToAllModal = false; } /** * Apply the current configuration to all other servers owned by the user. */ public function applyToAllServers(): void { $userId = Auth::id(); if (!$userId) { $this->error = 'You must be logged in to apply configurations'; $this->showErrorModal = true; return; } // Verify we have a saved configuration $matchingConfig = $this->getMatchingSavedConfiguration(); if (!$matchingConfig) { $this->error = 'Current configuration must be saved before applying to all servers'; $this->showErrorModal = true; return; } $otherServers = $this->getUserOtherServers(); if (count($otherServers) === 0) { $this->error = 'No other servers found to apply configuration to'; $this->showErrorModal = true; return; } $this->loading = true; $results = []; try { foreach ($otherServers as $serverData) { $server = Server::with('node')->find($serverData['id']); if (!$server || !$server->node) { $results[] = [ 'server_id' => $serverData['id'], 'hostname' => $serverData['hostname'] ?? 'Unknown', 'success' => false, 'message' => 'Server or node not found', ]; continue; } try { // Apply configuration to this server $this->applyConfigurationToServer($server, $matchingConfig); $results[] = [ 'server_id' => $server->id, 'hostname' => $server->hostname ?? 'VPS ' . $server->vm_id, 'success' => true, 'message' => 'Configuration applied successfully', ]; } catch (\Exception $e) { Log::error("Failed to apply firewall config to server {$server->id}: " . $e->getMessage()); $results[] = [ 'server_id' => $server->id, 'hostname' => $server->hostname ?? 'VPS ' . $server->vm_id, 'success' => false, 'message' => 'Failed: ' . $e->getMessage(), ]; } } $this->applyToAllResults = $results; $successCount = collect($results)->where('success', true)->count(); $totalCount = count($results); if ($successCount === $totalCount) { $this->dispatch('notify', "Configuration applied to all {$successCount} servers successfully", 'success'); } elseif ($successCount > 0) { $this->dispatch('notify', "Configuration applied to {$successCount} of {$totalCount} servers", 'warning'); } else { $this->error = 'Failed to apply configuration to any servers'; $this->showErrorModal = true; } } catch (\Exception $e) { Log::error('Failed to apply configuration to all servers: ' . $e->getMessage()); $this->error = 'Failed to apply configuration: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; } } /** * Apply a saved configuration to a specific server. */ protected function applyConfigurationToServer(Server $server, array $config): void { $savedRules = $config['firewall_rules'] ?? []; $incomingPolicy = $config['incoming_policy'] ?? 'DROP'; $outgoingPolicy = $config['outgoing_policy'] ?? 'ACCEPT'; // Step 1: Update policies $response = $this->firewallRequest( '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/options', 'put', [ 'policy_in' => $incomingPolicy, 'policy_out' => $outgoingPolicy, 'dhcp' => 0, 'ndp' => 1, ] ); if (!$response->successful()) { throw new \Exception('Failed to update firewall policies'); } // Step 2: Get existing rules and delete them $existingRulesResponse = $this->firewallRequest( '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules' ); if ($existingRulesResponse->successful()) { $existingRules = $existingRulesResponse->json('data') ?? []; foreach (array_reverse($existingRules) as $rule) { if (isset($rule['pos'])) { $this->firewallRequest( '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules/' . $rule['pos'], 'delete' ); } } } // Step 3: Create new rules from saved configuration if (is_array($savedRules)) { foreach ($savedRules as $savedRule) { $rulePayload = [ 'type' => $savedRule['type'] ?? 'in', 'action' => $savedRule['action'] ?? 'ACCEPT', 'enable' => isset($savedRule['enable']) ? (int)$savedRule['enable'] : 1, ]; if (!empty($savedRule['source'])) $rulePayload['source'] = $savedRule['source']; if (!empty($savedRule['dest'])) $rulePayload['dest'] = $savedRule['dest']; if (!empty($savedRule['proto'])) $rulePayload['proto'] = $savedRule['proto']; if (!empty($savedRule['dport'])) $rulePayload['dport'] = $savedRule['dport']; if (!empty($savedRule['sport'])) $rulePayload['sport'] = $savedRule['sport']; if (!empty($savedRule['macro'])) $rulePayload['macro'] = $savedRule['macro']; if (!empty($savedRule['comment'])) $rulePayload['comment'] = $savedRule['comment']; if (!empty($savedRule['log']) && $savedRule['log'] !== 'nolog') $rulePayload['log'] = $savedRule['log']; // Skip iface as it may not exist on other servers $this->firewallRequest( '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules', 'post', $rulePayload ); } } } public function render() { return view('proxmox::firewall')->layoutData([ 'sidebar' => true, ]); } }