diff --git a/extensions/Servers/Proxmox/Livewire/Firewall.php b/extensions/Servers/Proxmox/Livewire/Firewall.php index a2ca45e3c..1987f76bb 100644 --- a/extensions/Servers/Proxmox/Livewire/Firewall.php +++ b/extensions/Servers/Proxmox/Livewire/Firewall.php @@ -2,37 +2,41 @@ 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 Carbon\Carbon; -class Firewall extends Component +class FirewallNew extends Component { + // Server & Core State public bool $firewallEnabled = false; - public array $rules = []; - public array $aliases = []; - public array $ipsets = []; public bool $loading = false; public string $error = ''; public bool $showErrorModal = false; - public bool $showRuleForm = false; + + // Network Adapters public array $networkAdapters = []; public array $adapterFirewallStates = []; - public array $firewallLogs = []; - public int $logLimit = 1000; - public array $availableIps = []; - public bool $showDeleteModal = false; - public ?int $deletingRulePos = null; - public string $logError = ''; - // Rule creation/editing - public ?int $editingRulePos = null; + // Firewall Options (policies, filters) + public string $policyIn = 'DROP'; + public string $policyOut = 'ACCEPT'; + public bool $ipFilter = true; + public bool $macFilter = true; + 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 = 'tcp'; + public string $ruleProto = ''; public string $ruleDport = ''; public string $ruleSport = ''; public string $ruleComment = ''; @@ -40,45 +44,173 @@ class Firewall extends Component public string $ruleMacro = ''; public string $ruleIface = ''; public string $ruleLog = 'nolog'; + public bool $showDeleteRuleModal = false; + public ?int $deletingRuleIndex = null; - // Alias management + // 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' + ]; + + // 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; - // IPSet management + // 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 = 1000; + public string $logError = ''; + + // 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->loadNetworkAdapters(); + $this->loadAliases(); + $this->loadIPSets(); $this->loadFirewallLogs(); } + + // ==================== AVAILABLE IPS ==================== protected function loadAvailableIps(): void { $allIps = collect(); + if ($this->server->primaryIpv4) { - $allIps->push(['ip' => $this->server->primaryIpv4->ip, 'type' => 'IPv4', 'label' => 'Primary']); + $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']); + $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' => 'Assigned']); + $allIps->push([ + 'ip' => $ipAddress->ip, + 'type' => strtoupper($ipAddress->type), + 'label' => 'Additional' + ]); } + $this->availableIps = $allIps->unique('ip')->values()->toArray(); } - /** - * Make a firewall API request using the dedicated firewall credentials - */ + // ==================== FIREWALL API REQUEST ==================== + protected function firewallRequest(string $url, string $method = 'get', array $data = []): \Illuminate\Http\Client\Response { $baseUrl = 'https://pve.virtnet.bond'; @@ -87,141 +219,214 @@ class Firewall extends Component $secret = '0a439967-0a44-4b94-8906-c36b3a40cf68'; $fullUrl = rtrim($baseUrl, '/') . '/api2/json' . $url; - - // Proxmox API token format: PVEAPIToken=USER@REALM!TOKENID=SECRET $authHeader = 'PVEAPIToken=' . $user . '!' . $tokenId . '=' . $secret; - // For firewall log endpoint, accept text/plain as well - $acceptHeader = str_contains($url, '/firewall/log') - ? 'application/json, text/plain, */*' + $acceptHeader = str_contains($url, '/firewall/log') + ? 'application/json, text/plain, */*' : 'application/json'; $request = Http::withHeaders([ 'Authorization' => $authHeader, 'Accept' => $acceptHeader, - ])->withOptions([ - 'verify' => true, - ]); - - // For PUT/POST requests, Proxmox API expects form data - if (in_array(strtolower($method), ['put', 'post']) && !empty($data)) { - return $request->asForm()->$method($fullUrl, $data)->throw(); - } - - // For GET requests, if data is provided, it should be query parameters - if (strtolower($method) === 'get' && !empty($data)) { - $queryString = http_build_query($data); - $fullUrl .= (str_contains($fullUrl, '?') ? '&' : '?') . $queryString; - return $request->$method($fullUrl)->throw(); - } - - return $request->$method($fullUrl)->throw(); + ])->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; } - public function loadFirewallOptions(): void + // ==================== 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; + + 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; + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config', + 'put', + [$adapter . '.firewall' => $newState ? 1 : 0] + ); + + if ($response->successful()) { + $this->adapterFirewallStates[$adapter] = $newState; + $this->notify('Adapter firewall ' . ($newState ? 'enabled' : 'disabled'), 'success'); + } else { + $this->error = 'Failed to toggle adapter firewall'; + $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' ); - $data = $response->json()['data'] ?? []; - $this->firewallEnabled = isset($data['enable']) && $data['enable'] == 1; - $this->error = ''; - $this->showErrorModal = false; - } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - // Try to extract more detailed error from response if available - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } + + 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'); + $this->ipFilter = ($options['ipfilter'] ?? 1) == 1; + $this->macFilter = ($options['macfilter'] ?? 1) == 1; } - $this->error = 'Failed to load firewall options: ' . $errorMessage; - $this->showErrorModal = true; - $this->firewallEnabled = false; + } catch (\Exception $e) { + Log::error('Failed to load firewall options: ' . $e->getMessage()); } } public function toggleFirewall(): void { $this->loading = true; + try { - // Store the current state before toggling - $currentState = $this->firewallEnabled; - // Calculate the new desired state (opposite of current) - $newState = $currentState ? 0 : 1; - $payload = ['enable' => $newState]; + $newState = !$this->firewallEnabled; + // Always force DHCP and NDP (router advertisements) to 0 for security $response = $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options', 'put', - $payload + [ + 'enable' => $newState ? 1 : 0, + 'dhcp' => 0, // Always disabled + 'ndp' => 0, // Always disabled (prevents router advertisements) + ] ); - + if ($response->successful()) { - // Reload from API to get the actual state (don't assume it worked) - $this->loadFirewallOptions(); - $this->notify('Firewall ' . ($this->firewallEnabled ? 'enabled' : 'disabled') . ' successfully', 'success'); + $this->firewallEnabled = $newState; + $this->notify('Firewall ' . ($newState ? 'enabled' : 'disabled'), 'success'); } else { - $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; - $this->notify('Failed to toggle firewall: ' . $message, 'error'); - // Reload to ensure UI matches actual state - $this->loadFirewallOptions(); + $this->error = 'Failed to toggle firewall'; + $this->showErrorModal = true; } - } catch (\Illuminate\Http\Client\RequestException $e) { - $errorMessage = $e->getMessage(); - // Try to extract more detailed error from response - if ($e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } - $this->error = 'Failed to toggle firewall: ' . $errorMessage; - $this->showErrorModal = true; - // Reload to ensure UI matches actual state - $this->loadFirewallOptions(); } catch (\Exception $e) { $this->error = 'Failed to toggle firewall: ' . $e->getMessage(); $this->showErrorModal = true; - // Reload to ensure UI matches actual state - $this->loadFirewallOptions(); } finally { $this->loading = false; } } - public function loadFirewallRules(): void + 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, + 'ipfilter' => $this->ipFilter ? 1 : 0, + 'macfilter' => $this->macFilter ? 1 : 0, + 'dhcp' => 0, // Always disabled + 'ndp' => 0, // Always disabled + ] + ); + + 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 ==================== + + protected function loadFirewallRules(): void { try { $response = $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules' ); - $data = $response->json()['data'] ?? []; - $this->rules = is_array($data) ? $data : []; - $this->error = ''; - $this->showErrorModal = false; - // Dispatch event to sync Alpine.js rules array - $this->dispatch('rulesUpdated'); - } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - // Try to extract more detailed error from response if available - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } + + if ($response->successful()) { + $this->rules = $response->json()['data'] ?? []; } - $this->error = 'Failed to load firewall rules: ' . $errorMessage; - $this->showErrorModal = true; - $this->rules = []; + } catch (\Exception $e) { + Log::error('Failed to load firewall rules: ' . $e->getMessage()); } } @@ -229,17 +434,17 @@ class Firewall extends Component { $this->resetRuleForm(); - // Set default destination to first IP address + // Set default destination to first IP if (!empty($this->availableIps)) { $this->ruleDest = $this->availableIps[0]['ip']; } - // Set default interface to first network adapter - if (!empty($this->networkAdapters) && isset($this->networkAdapters[0]) && isset($this->networkAdapters[0]['name'])) { + // Set default interface to first adapter + if (!empty($this->networkAdapters)) { $this->ruleIface = $this->networkAdapters[0]['name']; } - $this->editingRulePos = null; + $this->editingRuleIndex = null; $this->showRuleForm = true; } @@ -252,13 +457,14 @@ class Firewall extends Component } $rule = $this->rules[$index]; - $this->editingRulePos = $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'] ?? 'tcp'; + $this->ruleProto = $rule['proto'] ?? ''; $this->ruleDport = $rule['dport'] ?? ''; $this->ruleSport = $rule['sport'] ?? ''; $this->ruleComment = $rule['comment'] ?? ''; @@ -268,76 +474,61 @@ class Firewall extends Component $this->ruleLog = $rule['log'] ?? 'nolog'; } - public function resetRuleForm(): void - { - $this->ruleAction = 'ACCEPT'; - $this->ruleSource = ''; - $this->ruleDest = ''; - $this->ruleProto = 'tcp'; - $this->ruleDport = ''; - $this->ruleSport = ''; - $this->ruleComment = ''; - $this->ruleEnable = true; - $this->ruleMacro = ''; - $this->ruleIface = ''; - $this->ruleLog = 'nolog'; - } - public function saveRule(): void { $this->loading = true; + try { - // Build payload - always include action, enable, and type - // Type defaults to 'in' for incoming rules (can be 'in' or 'out') $payload = [ 'action' => $this->ruleAction, + 'type' => $this->ruleType, 'enable' => $this->ruleEnable ? 1 : 0, - 'type' => 'in', // Default to incoming rule ]; - - // Add optional parameters only if they have values - // Note: Proxmox allows rules with just action, but some parameters may be required in certain contexts - if (!empty(trim($this->ruleSource))) { - $payload['source'] = trim($this->ruleSource); - } - // Destination is required, always include it - 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->ruleMacro))) { $payload['macro'] = trim($this->ruleMacro); } - // Interface is required, always include it + if (!empty(trim($this->ruleIface))) { $payload['iface'] = trim($this->ruleIface); } - if (!empty(trim($this->ruleLog))) { - $payload['log'] = trim($this->ruleLog); + + 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->editingRulePos !== null && isset($this->rules[$this->editingRulePos])) { - // Update existing rule - $rule = $this->rules[$this->editingRulePos]; - $pos = $rule['pos']; + 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 { - // Create new rule $response = $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules', 'post', @@ -346,9 +537,9 @@ class Firewall extends Component } if ($response->successful()) { - $this->notify('Rule ' . ($this->editingRulePos !== null ? 'updated' : 'created') . ' successfully', 'success'); + $this->notify('Rule ' . ($this->editingRuleIndex !== null ? 'updated' : 'created') . ' successfully', 'success'); $this->resetRuleForm(); - $this->editingRulePos = null; + $this->editingRuleIndex = null; $this->showRuleForm = false; $this->loadFirewallRules(); } else { @@ -357,30 +548,7 @@ class Firewall extends Component $this->showErrorModal = true; } } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - $fullErrorDetails = ''; - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors'])) { - // Try to get the full error details - $errorMessages = []; - foreach ($errorData['errors'] as $error) { - if (is_array($error)) { - $errorMessages[] = json_encode($error); - } else { - $errorMessages[] = $error; - } - } - $fullErrorDetails = implode(' ', $errorMessages); - $errorMessage = !empty($fullErrorDetails) ? $fullErrorDetails : ($errorData['errors'][0]['message'] ?? $errorMessage); - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - if (isset($errorData['errors'])) { - $errorMessage .= ' ' . json_encode($errorData['errors']); - } - } - } - $this->error = 'Failed to save rule: ' . $errorMessage; + $this->error = 'Failed to save rule: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; @@ -391,22 +559,14 @@ class Firewall extends Component { try { if (!isset($this->rules[$index])) { - $this->error = 'Rule not found'; - $this->showErrorModal = true; return; } $rule = $this->rules[$index]; $pos = $rule['pos']; - - // Toggle the enable state (0 = disabled, 1 = enabled) $currentEnable = isset($rule['enable']) ? (int)$rule['enable'] : 1; $newEnable = $currentEnable == 0 ? 1 : 0; - // Optimistically update the local state for immediate UI feedback - $this->rules[$index]['enable'] = $newEnable; - - // Update only the enable field $response = $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos, 'put', @@ -414,30 +574,14 @@ class Firewall extends Component ); if ($response->successful()) { - $this->notify('Rule ' . ($newEnable == 1 ? 'enabled' : 'disabled') . ' successfully', 'success'); - // Reload rules to ensure data consistency + $this->notify('Rule ' . ($newEnable == 1 ? 'enabled' : 'disabled'), 'success'); $this->loadFirewallRules(); } else { - // Revert the optimistic update on failure - $this->rules[$index]['enable'] = $currentEnable; - $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; - $this->error = 'Failed to toggle rule: ' . $message; + $this->error = 'Failed to toggle rule'; $this->showErrorModal = true; } } catch (\Exception $e) { - // Revert any optimistic updates - $this->loadFirewallRules(); - - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } - $this->error = 'Failed to toggle rule: ' . $errorMessage; + $this->error = 'Failed to toggle rule: ' . $e->getMessage(); $this->showErrorModal = true; } } @@ -445,31 +589,23 @@ class Firewall extends Component public function confirmDeleteRule(int $index): void { if (!isset($this->rules[$index])) { - $this->error = 'Rule not found'; - $this->showErrorModal = true; return; } - $this->deletingRulePos = $index; - $this->showDeleteModal = true; - } - - public function cancelDeleteRule(): void - { - $this->deletingRulePos = null; - $this->showDeleteModal = false; + $this->deletingRuleIndex = $index; + $this->showDeleteRuleModal = true; } public function deleteRule(): void { - if ($this->deletingRulePos === null || !isset($this->rules[$this->deletingRulePos])) { + if ($this->deletingRuleIndex === null || !isset($this->rules[$this->deletingRuleIndex])) { return; } - $rule = $this->rules[$this->deletingRulePos]; + $rule = $this->rules[$this->deletingRuleIndex]; $pos = $rule['pos']; $this->loading = true; - $this->showDeleteModal = false; + $this->showDeleteRuleModal = false; try { $response = $this->firewallRequest( @@ -481,32 +617,27 @@ class Firewall extends Component $this->notify('Rule deleted successfully', 'success'); $this->loadFirewallRules(); } else { - $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; - $this->error = 'Failed to delete rule: ' . $message; + $this->error = 'Failed to delete rule'; $this->showErrorModal = true; } } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } - $this->error = 'Failed to delete rule: ' . $errorMessage; + $this->error = 'Failed to delete rule: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; - $this->deletingRulePos = null; + $this->deletingRuleIndex = null; } } + public function cancelDeleteRule(): void + { + $this->deletingRuleIndex = null; + $this->showDeleteRuleModal = false; + } + public function reorderRules(int $fromPos, int $toPos): void { try { - // Proxmox API uses PUT with moveto parameter to reorder rules $response = $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $fromPos, 'put', @@ -514,114 +645,148 @@ class Firewall extends Component ); if ($response->successful()) { - $this->notify('Rule reordered successfully', 'success'); + $this->notify('Rules reordered successfully', 'success'); $this->loadFirewallRules(); } else { - $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; - $this->error = 'Failed to reorder rule: ' . $message; + $this->error = 'Failed to reorder rules'; $this->showErrorModal = true; - \Log::error('Failed to reorder firewall rule', [ - 'from' => $fromPos, - 'to' => $toPos, - 'response' => $response->json() - ]); } } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - try { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } catch (\Exception $jsonError) { - // Response might not be JSON - } - } - $this->error = 'Failed to reorder rule: ' . $errorMessage; + $this->error = 'Failed to reorder rules: ' . $e->getMessage(); $this->showErrorModal = true; - \Log::error('Exception reordering firewall rule', [ - 'from' => $fromPos, - 'to' => $toPos, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); } } - public function loadAliases(): void + 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' ); - $data = $response->json()['data'] ?? []; - $this->aliases = is_array($data) ? $data : []; + + if ($response->successful()) { + $this->aliases = $response->json()['data'] ?? []; + } } catch (\Exception $e) { - $this->aliases = []; + Log::error('Failed to load aliases: ' . $e->getMessage()); } } - public function createAlias(): void + public function startCreateAlias(): void { - if (empty($this->aliasName) || empty($this->aliasCidr)) { - $this->error = 'Name and CIDR are required'; - $this->showErrorModal = true; - return; + $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' => $this->aliasName, - 'cidr' => $this->aliasCidr, + 'name' => trim($this->aliasName), + 'cidr' => trim($this->aliasCidr), ]; - if ($this->aliasComment) { - $payload['comment'] = $this->aliasComment; + + if (!empty(trim($this->aliasComment))) { + $payload['comment'] = trim($this->aliasComment); } - $response = $this->firewallRequest( - '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases', - 'post', - $payload - ); + 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 created successfully', 'success'); - $this->aliasName = ''; - $this->aliasCidr = ''; - $this->aliasComment = ''; + $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 create alias: ' . $message; + $this->error = 'Failed to save alias: ' . $message; $this->showErrorModal = true; } } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } - $this->error = 'Failed to create alias: ' . $errorMessage; + $this->error = 'Failed to save alias: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; } } - public function deleteAlias(string $name): void + 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/' . $name, + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases/' . $this->deletingAliasName, 'delete' ); @@ -629,55 +794,77 @@ class Firewall extends Component $this->notify('Alias deleted successfully', 'success'); $this->loadAliases(); } else { - $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; - $this->error = 'Failed to delete alias: ' . $message; + $this->error = 'Failed to delete alias'; $this->showErrorModal = true; } } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } - $this->error = 'Failed to delete alias: ' . $errorMessage; + $this->error = 'Failed to delete alias: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; + $this->deletingAliasName = null; } } - public function loadIpsets(): void + 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' ); - $data = $response->json()['data'] ?? []; - $this->ipsets = is_array($data) ? $data : []; + + if ($response->successful()) { + $this->ipsets = $response->json()['data'] ?? []; + } } catch (\Exception $e) { - $this->ipsets = []; + Log::error('Failed to load IPSets: ' . $e->getMessage()); } } - public function createIpset(): void + public function startCreateIPSet(): void { - if (empty($this->ipsetName)) { - $this->error = 'Name is required'; - $this->showErrorModal = true; - return; - } + $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' => $this->ipsetName, + 'name' => trim($this->ipsetName), ]; - if ($this->ipsetComment) { - $payload['comment'] = $this->ipsetComment; + + if (!empty(trim($this->ipsetComment))) { + $payload['comment'] = trim($this->ipsetComment); } $response = $this->firewallRequest( @@ -687,548 +874,357 @@ class Firewall extends Component ); if ($response->successful()) { - $this->notify('IP set created successfully', 'success'); - $this->ipsetName = ''; - $this->ipsetComment = ''; - $this->loadIpsets(); + $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 IP set: ' . $message; + $this->error = 'Failed to create IPSet: ' . $message; $this->showErrorModal = true; } } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } - $this->error = 'Failed to create IP set: ' . $errorMessage; + $this->error = 'Failed to create IPSet: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; } } - public function deleteIpset(string $name): void + 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/' . $name, + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->deletingIPSetName, 'delete' ); if ($response->successful()) { - $this->notify('IP set deleted successfully', 'success'); - $this->loadIpsets(); + $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 { - $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; - $this->error = 'Failed to delete IP set: ' . $message; + $this->error = 'Failed to delete IPSet'; $this->showErrorModal = true; } } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } - $this->error = 'Failed to delete IP set: ' . $errorMessage; + $this->error = 'Failed to delete IPSet: ' . $e->getMessage(); $this->showErrorModal = true; } finally { $this->loading = false; + $this->deletingIPSetName = null; } } - public function loadNetworkAdapters(): void + public function cancelDeleteIPSet(): void { - try { - $proxmox = new Proxmox; - $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; - - $this->networkAdapters = []; - $this->adapterFirewallStates = []; - - foreach ($config as $key => $value) { - if (!is_string($key) || !str_starts_with($key, 'net')) { - continue; - } - - $configString = (string) $value; - $segments = array_filter(array_map('trim', explode(',', $configString))); - $model = 'virtio'; - $mac = null; - $firewallEnabled = false; - - foreach ($segments as $segment) { - if (!str_contains($segment, '=')) { - continue; - } - - [$segmentKey, $segmentValue] = array_map('trim', explode('=', $segment, 2)); - $segmentKeyLower = strtolower($segmentKey); - - if (in_array($segmentKeyLower, ['virtio', 'e1000', 'e1000e', 'rtl8139', 'vmxnet3', 'pcnet'], true)) { - $model = $segmentKeyLower; - $mac = $segmentValue; - } elseif ($segmentKeyLower === 'firewall') { - $firewallEnabled = ($segmentValue == '1' || $segmentValue === '1'); - } - } - - $this->networkAdapters[] = [ - 'name' => $key, - 'model' => $model, - 'mac' => $mac, - ]; - $this->adapterFirewallStates[$key] = $firewallEnabled; - } - } catch (\Exception $e) { - $this->networkAdapters = []; - $this->adapterFirewallStates = []; - } + $this->deletingIPSetName = null; + $this->showDeleteIPSetModal = false; } - - public function toggleAdapterFirewall(string $adapterName): void + + 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 { - $this->loading = true; try { - $proxmox = new Proxmox; - $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; - - if (!isset($config[$adapterName])) { - $this->error = 'Network adapter not found: ' . $adapterName; - $this->showErrorModal = true; - $this->loading = false; - return; - } - - $currentConfig = (string) $config[$adapterName]; - $segments = array_filter(array_map('trim', explode(',', $currentConfig))); - $newSegments = []; - $firewallFound = false; - $currentFirewallState = $this->adapterFirewallStates[$adapterName] ?? false; - $newFirewallState = !$currentFirewallState; - - foreach ($segments as $segment) { - if (!str_contains($segment, '=')) { - $newSegments[] = $segment; - continue; - } - - [$segmentKey, $segmentValue] = array_map('trim', explode('=', $segment, 2)); - - if (strtolower($segmentKey) === 'firewall') { - $firewallFound = true; - if ($newFirewallState) { - $newSegments[] = 'firewall=1'; - } - // If disabling, we skip adding firewall=1, effectively removing it - } else { - $newSegments[] = $segment; - } - } - - // If firewall wasn't in the config and we're enabling it, add it - if (!$firewallFound && $newFirewallState) { - $newSegments[] = 'firewall=1'; - } - - $newConfig = implode(',', $newSegments); - - $proxmox->request( - '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config', - 'put', - [$adapterName => $newConfig] + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $name ); - $this->adapterFirewallStates[$adapterName] = $newFirewallState; - $this->notify('Firewall ' . ($newFirewallState ? 'enabled' : 'disabled') . ' for ' . $adapterName, 'success'); - $this->loadNetworkAdapters(); // Reload to ensure consistency - } catch (\Exception $e) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - $errorData = $e->response()->json(); - if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { - $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; - } elseif (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } + if ($response->successful()) { + $this->ipsetMembers = $response->json()['data'] ?? []; } - $this->error = 'Failed to toggle firewall for adapter: ' . $errorMessage; + } 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 loadFirewallLogs(): void + public function confirmDeleteIPSetMember(string $cidr): void { - $this->logError = ''; + $this->deletingMemberCidr = $cidr; + $this->showDeleteIPSetMemberModal = true; + } + + public function deleteIPSetMember(): void + { + if (!$this->selectedIPSetName || !$this->deletingMemberCidr) { + return; + } + + $this->loading = true; + $this->showDeleteIPSetMemberModal = false; + try { - // Proxmox API: GET /api2/json/nodes/{node}/qemu/{vmid}/firewall/log - // Documentation: https://pve.proxmox.com/pve-docs/api-viewer/#/nodes/{node}/qemu/{vmid}/firewall/log - // Returns: text/plain format with log entries, one per line - // Fetch logs in batches and collect all of them - $allRawLogs = []; - $start = 0; - $batchSize = 500; - $maxLogs = $this->logLimit; - $fetchedCount = 0; - $totalBodyLength = 0; - $lastContentType = ''; - $lastIsJson = false; + // URL encode the CIDR for the DELETE request + $encodedCidr = urlencode($this->deletingMemberCidr); - // Keep fetching until we have all logs or reach the limit - do { - $response = $this->firewallRequest( - '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log', - 'get', - [ - 'limit' => $batchSize, - 'start' => $start - ] - ); + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->selectedIPSetName . '/' . $encodedCidr, + 'delete' + ); - if (! $response->successful()) { - $errorData = []; - try { - $errorData = $response->json(); - } catch (\Exception $e) { - // Response might not be JSON - } - $errorMsg = $errorData['message'] ?? 'HTTP ' . $response->status(); - $this->logError = 'Failed to fetch logs: ' . $errorMsg; - \Log::warning('Firewall log API request failed', [ - 'status' => $response->status(), - 'body' => substr($response->body(), 0, 500), - 'server_id' => $this->server->id, - 'vm_id' => $this->server->vm_id, - ]); - $this->firewallLogs = []; - return; - } - - // Proxmox firewall log endpoint can return either text/plain or JSON format - // Try to parse as JSON first, then fall back to text/plain - $body = $response->body(); - $totalBodyLength += strlen($body); - $batchRawLogs = []; - - // Check if response is JSON - $contentType = $response->header('Content-Type'); - $lastContentType = $contentType ?? ''; - $isJson = str_contains($contentType ?? '', 'json'); - $lastIsJson = $isJson; - - if ($isJson) { - try { - $jsonData = $response->json(); - // Proxmox may return logs in different formats: - // 1. { "data": [ {"n": 1, "t": "log text"}, ... ] } - // 2. { "data": [ "line1", "line2", ... ] } - // 3. { "data": "line1\nline2\n..." } - // 4. [ {"n": 1, "t": "log text"}, ... ] - - if (isset($jsonData['data'])) { - if (is_array($jsonData['data'])) { - // Handle array of log objects or strings - foreach ($jsonData['data'] as $item) { - if (is_array($item) && isset($item['t'])) { - // Proxmox format: {"n": 1, "t": "log text"} - $batchRawLogs[] = $item['t']; - } elseif (is_string($item)) { - // Plain string format - $batchRawLogs[] = $item; - } elseif (is_array($item)) { - // Other array format, try to get text from various fields - $text = $item['text'] ?? $item['message'] ?? $item['line'] ?? json_encode($item); - $batchRawLogs[] = $text; - } - } - } elseif (is_string($jsonData['data'])) { - // Multi-line string format - $lines = preg_split('/\r\n|\r|\n/', $jsonData['data']); - $batchRawLogs = array_filter($lines, static function ($line) { - return ! empty(trim($line)); - }); - } - } elseif (is_array($jsonData)) { - // Array at root level - foreach ($jsonData as $item) { - if (is_array($item) && isset($item['t'])) { - $batchRawLogs[] = $item['t']; - } elseif (is_string($item)) { - $batchRawLogs[] = $item; - } - } - } - } catch (\Exception $e) { - // Fall through to text parsing - \Log::warning('Failed to parse JSON response, falling back to text', [ - 'error' => $e->getMessage(), - 'response_sample' => substr($body, 0, 500) - ]); - } - } - - // If no JSON data or JSON parsing failed, try text/plain format - if (empty($batchRawLogs) && ! empty($body)) { - // Split by newlines (handle both \n and \r\n) - $lines = preg_split('/\r\n|\r|\n/', $body); - $batchRawLogs = array_filter($lines, static function ($line) { - return ! empty(trim($line)); - }); - } - - // Re-index array after filtering - $batchRawLogs = array_values($batchRawLogs); - - // Add this batch to all logs - $allRawLogs = array_merge($allRawLogs, $batchRawLogs); - $fetchedCount = count($batchRawLogs); - - // Move to next batch - $start += $batchSize; - - // Break if we got fewer logs than requested (meaning we've reached the end) - // or if we've reached our limit - if ($fetchedCount < $batchSize || count($allRawLogs) >= $maxLogs) { - break; - } - } while (true); - - // Limit to maxLogs - if (count($allRawLogs) > $maxLogs) { - $allRawLogs = array_slice($allRawLogs, 0, $maxLogs); - } - - $rawLogs = $allRawLogs; - - \Log::info('Firewall logs fetched from Proxmox API', [ - 'raw_count' => count($rawLogs), - 'total_body_length' => $totalBodyLength, - 'last_content_type' => $lastContentType, - 'last_is_json' => $lastIsJson, - 'batches_fetched' => ($start / $batchSize), - 'first_log_type' => ! empty($rawLogs) ? gettype($rawLogs[0]) : 'empty', - 'first_log_length' => ! empty($rawLogs) && is_string($rawLogs[0]) ? strlen($rawLogs[0]) : 'N/A', - 'sample' => ! empty($rawLogs) ? substr(is_array($rawLogs[0]) ? json_encode($rawLogs[0]) : $rawLogs[0], 0, 300) : null, - 'first_3_logs_full' => array_slice($rawLogs, 0, 3), - ]); - - $this->firewallLogs = []; - - $successfullyParsed = 0; - $skippedEmpty = 0; - $skippedInvalid = 0; - - foreach ($rawLogs as $index => $logEntry) { - $logText = ''; - - if (is_string($logEntry)) { - $logText = trim($logEntry); - } elseif (is_array($logEntry)) { - // Handle various possible array formats from Proxmox API - // Note: 't' is the text field, 'n' is the line number - prioritize 't'! - $logText = trim( - $logEntry['t'] // Proxmox format: {"n": 1, "t": "log text"} - ?? $logEntry['text'] // Alternative text field - ?? $logEntry['raw'] // Raw log field - ?? $logEntry['message'] // Message field - ?? $logEntry['log'] // Log field - ?? $logEntry['line'] // Line field - ?? json_encode($logEntry) // Last resort: encode the whole thing - ); - } - - if ($logText === '' || strlen($logText) < 10) { - $skippedEmpty++; - continue; - } - - try { - $parsed = $this->parseFirewallLog($logText); - // Check if the log was actually parsed (has at least action or source/destination) - if ($parsed !== null && ($parsed['action'] !== null || $parsed['source'] !== null || $parsed['destination'] !== null)) { - $this->firewallLogs[] = $parsed; - $successfullyParsed++; - } else { - $skippedInvalid++; - if ($index < 5) { // Log first 5 unparseable entries for debugging - \Log::warning('Firewall log could not be parsed (missing key fields)', [ - 'entry' => substr($logText, 0, 300), - 'parsed' => $parsed - ]); - } - } - } catch (\Exception $e) { - $skippedInvalid++; - if ($index < 5) { // Log first 5 errors for debugging - \Log::warning('Failed to parse firewall log entry', [ - 'entry' => substr($logText, 0, 300), - 'error' => $e->getMessage() - ]); - } - } - } - - \Log::info('Firewall log parsing summary', [ - 'total_raw_logs' => count($rawLogs), - 'successfully_parsed' => $successfullyParsed, - 'skipped_empty' => $skippedEmpty, - 'skipped_invalid' => $skippedInvalid - ]); - - \Log::info('Firewall logs final count', [ - 'total_logs' => count($this->firewallLogs), - 'first_3_sources' => array_slice(array_column($this->firewallLogs, 'source'), 0, 3), - ]); - - if (empty($this->firewallLogs) && ! empty($rawLogs)) { - $this->logError = 'Logs received (' . count($rawLogs) . ') but could not be processed. Successfully parsed: ' . $successfullyParsed . ', Skipped invalid: ' . $skippedInvalid . '. Check Laravel logs for details.'; - } elseif (empty($this->firewallLogs)) { - $this->logError = 'No firewall logs available. Ensure firewall logging is enabled in Proxmox (Datacenter > Firewall > Options).'; + 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) { - $errorMessage = $e->getMessage(); - if (method_exists($e, 'response') && $e->response()) { - try { - $errorData = $e->response()->json(); - if (isset($errorData['message'])) { - $errorMessage = $errorData['message']; - } - } catch (\Exception $jsonError) { - // Response might not be JSON - } - } - $this->logError = 'Error loading logs: ' . $errorMessage; - \Log::error('Failed to load firewall logs', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - 'server_id' => $this->server->id, - 'vm_id' => $this->server->vm_id, - ]); - $this->firewallLogs = []; + $this->error = 'Failed to remove member: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + $this->deletingMemberCidr = null; } } - - protected function parseFirewallLog(string $logText): array + + public function cancelDeleteIPSetMember(): void { - $result = [ - 'raw' => $logText, - 'timestamp_display' => null, - 'timestamp_iso' => null, - 'action' => null, - 'action_badge_class' => 'bg-base/20 text-base/70', - 'level_numeric' => null, - 'level_label' => null, - 'level_badge_class' => 'bg-base/20 text-base/70', - 'protocol' => null, - 'source' => null, - 'source_port' => null, - 'destination' => null, - 'destination_port' => null, - 'iface' => null, + $this->deletingMemberCidr = null; + $this->showDeleteIPSetMemberModal = false; + } + + public function cancelIPSetMemberForm(): void + { + $this->memberCidr = ''; + $this->memberComment = ''; + $this->memberNomatch = false; + $this->showIPSetMemberForm = false; + } + + protected function resetIPSetForm(): void + { + $this->ipsetName = ''; + $this->ipsetComment = ''; + } + + // ==================== FIREWALL LOGS ==================== + + public function loadFirewallLogs(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log', + 'get', + ['limit' => $this->logLimit] + ); + + if ($response->successful()) { + $rawLogs = $response->json()['data'] ?? []; + $this->firewallLogs = $this->parseFirewallLogs($rawLogs); + $this->logError = ''; + } else { + $this->logError = 'Failed to load firewall logs'; + } + } catch (\Exception $e) { + $this->logError = 'Failed to load logs: ' . $e->getMessage(); + Log::error('Firewall logs error: ' . $e->getMessage()); + } + } + + 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; + } + } + + 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], ]; - // Extract level and interface (first tokens) - if (preg_match('/^\S+\s+(\d+)\s+([^\s]+)/', $logText, $matches)) { - $levelNumeric = (int) $matches[1]; - $iface = $matches[2]; - $priMap = [ - 0 => 'emerg', - 1 => 'alert', - 2 => 'crit', - 3 => 'err', - 4 => 'warning', - 5 => 'notice', - 6 => 'info', - 7 => 'debug', - ]; - $levelLabel = $priMap[$levelNumeric] ?? 'info'; - $levelBadgeMap = [ - 'emerg' => 'bg-error text-white', - 'alert' => 'bg-error/80 text-white', - 'crit' => 'bg-error/60 text-white', - 'err' => 'bg-error/20 text-error', - 'warning' => 'bg-warning/20 text-warning', - 'notice' => 'bg-primary/20 text-primary', - 'info' => 'bg-primary/20 text-primary', - 'debug' => 'bg-base/20 text-base/70', - ]; - - $result['level_numeric'] = $levelNumeric; - $result['level_label'] = strtoupper($levelLabel); - $result['level_badge_class'] = $levelBadgeMap[$levelLabel] ?? 'bg-base/20 text-base/70'; - $result['iface'] = $iface; - } - - // Extract timestamp and timezone - if (preg_match('/(\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2})\s+([+-]\d{4})/', $logText, $matches)) { - $timestamp = $matches[1]; - $tz = $matches[2]; - $tzNormalized = substr($tz, 0, 3) . ':' . substr($tz, 3); - try { - $carbon = Carbon::createFromFormat('d/M/Y:H:i:s P', $timestamp . ' ' . $tzNormalized); - $result['timestamp_iso'] = $carbon->toIso8601String(); - $result['timestamp_display'] = $carbon->format('Y-m-d H:i:s T'); - } catch (\Exception $e) { - $result['timestamp_display'] = str_replace('/', '-', $timestamp) . ' ' . $tzNormalized; + // 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; } } - // Extract action (REJECT, ACCEPT, DROP) - if (preg_match('/\b(REJECT|ACCEPT|DROP):/i', $logText, $matches)) { - $action = strtoupper($matches[1]); - $actionBadgeMap = [ - 'ACCEPT' => 'bg-success/20 text-success', - 'DROP' => 'bg-error/20 text-error', - 'REJECT' => 'bg-warning/20 text-warning', - ]; - $result['action'] = $action; - $result['action_badge_class'] = $actionBadgeMap[$action] ?? 'bg-base/20 text-base/70'; - } - - // Extract protocol - if (preg_match('/PROTO=([^\s]+)/', $logText, $matches)) { - $result['protocol'] = strtoupper($matches[1]); - } - - // Extract source/destination IPs and ports - if (preg_match('/SRC=([^\s]+)/', $logText, $matches)) { - $result['source'] = $matches[1]; - } - - if (preg_match('/DST=([^\s]+)/', $logText, $matches)) { - $result['destination'] = $matches[1]; - } - - if (preg_match('/SPT=(\d+)/', $logText, $matches)) { - $result['source_port'] = $matches[1]; - } - - if (preg_match('/DPT=(\d+)/', $logText, $matches)) { - $result['destination_port'] = $matches[1]; - } - - return $result; + // Map fields to standardized names + return [ + 'raw' => $line, + 'timestamp' => $fields['timestamp'] ?? '-', + 'timestamp_iso' => $this->convertTimestamp($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'] ?? '-', + ]; } - /** - * Fetch IP location data from ip-api.com - */ + 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 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 { - // Validate IP address if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return [ 'status' => 'fail', @@ -1236,21 +1232,19 @@ class Firewall extends Component ]; } - // Fetch location data from ip-api.com (HTTP endpoint) $response = Http::timeout(5)->get("http://ip-api.com/json/{$ip}"); if ($response->successful()) { $data = $response->json(); - // Check if the API returned success if (isset($data['status']) && $data['status'] === 'success') { return $data; - } else { - return [ - 'status' => 'fail', - 'message' => $data['message'] ?? 'Unable to fetch location data' - ]; } + + return [ + 'status' => 'fail', + 'message' => $data['message'] ?? 'Unable to fetch location data' + ]; } return [ @@ -1265,12 +1259,21 @@ class Firewall extends Component } } + // ==================== 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; + } + public function render() { - return view('proxmox::firewall')->layoutData([ - 'server' => $this->server, - 'status' => $this->status, - 'sidebar' => true, - ]); + return view('proxmox::firewall-new'); } } + diff --git a/extensions/Servers/Proxmox/Livewire/Firewall.php.backup b/extensions/Servers/Proxmox/Livewire/Firewall.php.backup new file mode 100644 index 000000000..a2ca45e3c --- /dev/null +++ b/extensions/Servers/Proxmox/Livewire/Firewall.php.backup @@ -0,0 +1,1276 @@ +server->loadMissing('ipAddresses'); + $this->loadAvailableIps(); + $this->loadFirewallOptions(); + $this->loadFirewallRules(); + $this->loadNetworkAdapters(); + $this->loadFirewallLogs(); + } + + protected function loadAvailableIps(): void + { + $allIps = collect(); + if ($this->server->primaryIpv4) { + $allIps->push(['ip' => $this->server->primaryIpv4->ip, 'type' => 'IPv4', 'label' => 'Primary']); + } + if ($this->server->primaryIpv6) { + $allIps->push(['ip' => $this->server->primaryIpv6->ip, 'type' => 'IPv6', 'label' => 'Primary']); + } + foreach ($this->server->ipAddresses as $ipAddress) { + $allIps->push(['ip' => $ipAddress->ip, 'type' => strtoupper($ipAddress->type), 'label' => 'Assigned']); + } + $this->availableIps = $allIps->unique('ip')->values()->toArray(); + } + + /** + * Make a firewall API request using the dedicated firewall credentials + */ + 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; + + // Proxmox API token format: PVEAPIToken=USER@REALM!TOKENID=SECRET + $authHeader = 'PVEAPIToken=' . $user . '!' . $tokenId . '=' . $secret; + + // For firewall log endpoint, accept text/plain as well + $acceptHeader = str_contains($url, '/firewall/log') + ? 'application/json, text/plain, */*' + : 'application/json'; + + $request = Http::withHeaders([ + 'Authorization' => $authHeader, + 'Accept' => $acceptHeader, + ])->withOptions([ + 'verify' => true, + ]); + + // For PUT/POST requests, Proxmox API expects form data + if (in_array(strtolower($method), ['put', 'post']) && !empty($data)) { + return $request->asForm()->$method($fullUrl, $data)->throw(); + } + + // For GET requests, if data is provided, it should be query parameters + if (strtolower($method) === 'get' && !empty($data)) { + $queryString = http_build_query($data); + $fullUrl .= (str_contains($fullUrl, '?') ? '&' : '?') . $queryString; + return $request->$method($fullUrl)->throw(); + } + + return $request->$method($fullUrl)->throw(); + } + + public function loadFirewallOptions(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options' + ); + $data = $response->json()['data'] ?? []; + $this->firewallEnabled = isset($data['enable']) && $data['enable'] == 1; + $this->error = ''; + $this->showErrorModal = false; + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + // Try to extract more detailed error from response if available + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to load firewall options: ' . $errorMessage; + $this->showErrorModal = true; + $this->firewallEnabled = false; + } + } + + public function toggleFirewall(): void + { + $this->loading = true; + try { + // Store the current state before toggling + $currentState = $this->firewallEnabled; + // Calculate the new desired state (opposite of current) + $newState = $currentState ? 0 : 1; + $payload = ['enable' => $newState]; + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options', + 'put', + $payload + ); + + if ($response->successful()) { + // Reload from API to get the actual state (don't assume it worked) + $this->loadFirewallOptions(); + $this->notify('Firewall ' . ($this->firewallEnabled ? 'enabled' : 'disabled') . ' successfully', 'success'); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->notify('Failed to toggle firewall: ' . $message, 'error'); + // Reload to ensure UI matches actual state + $this->loadFirewallOptions(); + } + } catch (\Illuminate\Http\Client\RequestException $e) { + $errorMessage = $e->getMessage(); + // Try to extract more detailed error from response + if ($e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to toggle firewall: ' . $errorMessage; + $this->showErrorModal = true; + // Reload to ensure UI matches actual state + $this->loadFirewallOptions(); + } catch (\Exception $e) { + $this->error = 'Failed to toggle firewall: ' . $e->getMessage(); + $this->showErrorModal = true; + // Reload to ensure UI matches actual state + $this->loadFirewallOptions(); + } finally { + $this->loading = false; + } + } + + public function loadFirewallRules(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules' + ); + $data = $response->json()['data'] ?? []; + $this->rules = is_array($data) ? $data : []; + $this->error = ''; + $this->showErrorModal = false; + // Dispatch event to sync Alpine.js rules array + $this->dispatch('rulesUpdated'); + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + // Try to extract more detailed error from response if available + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to load firewall rules: ' . $errorMessage; + $this->showErrorModal = true; + $this->rules = []; + } + } + + public function startCreateRule(): void + { + $this->resetRuleForm(); + + // Set default destination to first IP address + if (!empty($this->availableIps)) { + $this->ruleDest = $this->availableIps[0]['ip']; + } + + // Set default interface to first network adapter + if (!empty($this->networkAdapters) && isset($this->networkAdapters[0]) && isset($this->networkAdapters[0]['name'])) { + $this->ruleIface = $this->networkAdapters[0]['name']; + } + + $this->editingRulePos = 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->editingRulePos = $index; + $this->showRuleForm = true; + + $this->ruleAction = $rule['action'] ?? 'ACCEPT'; + $this->ruleSource = $rule['source'] ?? ''; + $this->ruleDest = $rule['dest'] ?? ''; + $this->ruleProto = $rule['proto'] ?? 'tcp'; + $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 resetRuleForm(): void + { + $this->ruleAction = 'ACCEPT'; + $this->ruleSource = ''; + $this->ruleDest = ''; + $this->ruleProto = 'tcp'; + $this->ruleDport = ''; + $this->ruleSport = ''; + $this->ruleComment = ''; + $this->ruleEnable = true; + $this->ruleMacro = ''; + $this->ruleIface = ''; + $this->ruleLog = 'nolog'; + } + + public function saveRule(): void + { + $this->loading = true; + try { + // Build payload - always include action, enable, and type + // Type defaults to 'in' for incoming rules (can be 'in' or 'out') + $payload = [ + 'action' => $this->ruleAction, + 'enable' => $this->ruleEnable ? 1 : 0, + 'type' => 'in', // Default to incoming rule + ]; + + // Add optional parameters only if they have values + // Note: Proxmox allows rules with just action, but some parameters may be required in certain contexts + if (!empty(trim($this->ruleSource))) { + $payload['source'] = trim($this->ruleSource); + } + // Destination is required, always include it + 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->ruleMacro))) { + $payload['macro'] = trim($this->ruleMacro); + } + // Interface is required, always include it + if (!empty(trim($this->ruleIface))) { + $payload['iface'] = trim($this->ruleIface); + } + if (!empty(trim($this->ruleLog))) { + $payload['log'] = trim($this->ruleLog); + } + + if ($this->editingRulePos !== null && isset($this->rules[$this->editingRulePos])) { + // Update existing rule + $rule = $this->rules[$this->editingRulePos]; + $pos = $rule['pos']; + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos, + 'put', + $payload + ); + } else { + // Create new rule + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules', + 'post', + $payload + ); + } + + if ($response->successful()) { + $this->notify('Rule ' . ($this->editingRulePos !== null ? 'updated' : 'created') . ' successfully', 'success'); + $this->resetRuleForm(); + $this->editingRulePos = 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) { + $errorMessage = $e->getMessage(); + $fullErrorDetails = ''; + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors'])) { + // Try to get the full error details + $errorMessages = []; + foreach ($errorData['errors'] as $error) { + if (is_array($error)) { + $errorMessages[] = json_encode($error); + } else { + $errorMessages[] = $error; + } + } + $fullErrorDetails = implode(' ', $errorMessages); + $errorMessage = !empty($fullErrorDetails) ? $fullErrorDetails : ($errorData['errors'][0]['message'] ?? $errorMessage); + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + if (isset($errorData['errors'])) { + $errorMessage .= ' ' . json_encode($errorData['errors']); + } + } + } + $this->error = 'Failed to save rule: ' . $errorMessage; + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function toggleRule(int $index): void + { + try { + if (!isset($this->rules[$index])) { + $this->error = 'Rule not found'; + $this->showErrorModal = true; + return; + } + + $rule = $this->rules[$index]; + $pos = $rule['pos']; + + // Toggle the enable state (0 = disabled, 1 = enabled) + $currentEnable = isset($rule['enable']) ? (int)$rule['enable'] : 1; + $newEnable = $currentEnable == 0 ? 1 : 0; + + // Optimistically update the local state for immediate UI feedback + $this->rules[$index]['enable'] = $newEnable; + + // Update only the enable field + $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') . ' successfully', 'success'); + // Reload rules to ensure data consistency + $this->loadFirewallRules(); + } else { + // Revert the optimistic update on failure + $this->rules[$index]['enable'] = $currentEnable; + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to toggle rule: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + // Revert any optimistic updates + $this->loadFirewallRules(); + + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to toggle rule: ' . $errorMessage; + $this->showErrorModal = true; + } + } + + public function confirmDeleteRule(int $index): void + { + if (!isset($this->rules[$index])) { + $this->error = 'Rule not found'; + $this->showErrorModal = true; + return; + } + + $this->deletingRulePos = $index; + $this->showDeleteModal = true; + } + + public function cancelDeleteRule(): void + { + $this->deletingRulePos = null; + $this->showDeleteModal = false; + } + + public function deleteRule(): void + { + if ($this->deletingRulePos === null || !isset($this->rules[$this->deletingRulePos])) { + return; + } + + $rule = $this->rules[$this->deletingRulePos]; + $pos = $rule['pos']; + $this->loading = true; + $this->showDeleteModal = 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 { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to delete rule: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to delete rule: ' . $errorMessage; + $this->showErrorModal = true; + } finally { + $this->loading = false; + $this->deletingRulePos = null; + } + } + + public function reorderRules(int $fromPos, int $toPos): void + { + try { + // Proxmox API uses PUT with moveto parameter to reorder rules + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $fromPos, + 'put', + ['moveto' => $toPos] + ); + + if ($response->successful()) { + $this->notify('Rule reordered successfully', 'success'); + $this->loadFirewallRules(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to reorder rule: ' . $message; + $this->showErrorModal = true; + \Log::error('Failed to reorder firewall rule', [ + 'from' => $fromPos, + 'to' => $toPos, + 'response' => $response->json() + ]); + } + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + try { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } catch (\Exception $jsonError) { + // Response might not be JSON + } + } + $this->error = 'Failed to reorder rule: ' . $errorMessage; + $this->showErrorModal = true; + \Log::error('Exception reordering firewall rule', [ + 'from' => $fromPos, + 'to' => $toPos, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + } + + public function loadAliases(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases' + ); + $data = $response->json()['data'] ?? []; + $this->aliases = is_array($data) ? $data : []; + } catch (\Exception $e) { + $this->aliases = []; + } + } + + public function createAlias(): void + { + if (empty($this->aliasName) || empty($this->aliasCidr)) { + $this->error = 'Name and CIDR are required'; + $this->showErrorModal = true; + return; + } + + $this->loading = true; + try { + $payload = [ + 'name' => $this->aliasName, + 'cidr' => $this->aliasCidr, + ]; + if ($this->aliasComment) { + $payload['comment'] = $this->aliasComment; + } + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases', + 'post', + $payload + ); + + if ($response->successful()) { + $this->notify('Alias created successfully', 'success'); + $this->aliasName = ''; + $this->aliasCidr = ''; + $this->aliasComment = ''; + $this->loadAliases(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to create alias: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to create alias: ' . $errorMessage; + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function deleteAlias(string $name): void + { + $this->loading = true; + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases/' . $name, + 'delete' + ); + + if ($response->successful()) { + $this->notify('Alias deleted successfully', 'success'); + $this->loadAliases(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to delete alias: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to delete alias: ' . $errorMessage; + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function loadIpsets(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset' + ); + $data = $response->json()['data'] ?? []; + $this->ipsets = is_array($data) ? $data : []; + } catch (\Exception $e) { + $this->ipsets = []; + } + } + + public function createIpset(): void + { + if (empty($this->ipsetName)) { + $this->error = 'Name is required'; + $this->showErrorModal = true; + return; + } + + $this->loading = true; + try { + $payload = [ + 'name' => $this->ipsetName, + ]; + if ($this->ipsetComment) { + $payload['comment'] = $this->ipsetComment; + } + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset', + 'post', + $payload + ); + + if ($response->successful()) { + $this->notify('IP set created successfully', 'success'); + $this->ipsetName = ''; + $this->ipsetComment = ''; + $this->loadIpsets(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to create IP set: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to create IP set: ' . $errorMessage; + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function deleteIpset(string $name): void + { + $this->loading = true; + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $name, + 'delete' + ); + + if ($response->successful()) { + $this->notify('IP set deleted successfully', 'success'); + $this->loadIpsets(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to delete IP set: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to delete IP set: ' . $errorMessage; + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function loadNetworkAdapters(): void + { + try { + $proxmox = new Proxmox; + $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; + + $this->networkAdapters = []; + $this->adapterFirewallStates = []; + + foreach ($config as $key => $value) { + if (!is_string($key) || !str_starts_with($key, 'net')) { + continue; + } + + $configString = (string) $value; + $segments = array_filter(array_map('trim', explode(',', $configString))); + $model = 'virtio'; + $mac = null; + $firewallEnabled = false; + + foreach ($segments as $segment) { + if (!str_contains($segment, '=')) { + continue; + } + + [$segmentKey, $segmentValue] = array_map('trim', explode('=', $segment, 2)); + $segmentKeyLower = strtolower($segmentKey); + + if (in_array($segmentKeyLower, ['virtio', 'e1000', 'e1000e', 'rtl8139', 'vmxnet3', 'pcnet'], true)) { + $model = $segmentKeyLower; + $mac = $segmentValue; + } elseif ($segmentKeyLower === 'firewall') { + $firewallEnabled = ($segmentValue == '1' || $segmentValue === '1'); + } + } + + $this->networkAdapters[] = [ + 'name' => $key, + 'model' => $model, + 'mac' => $mac, + ]; + $this->adapterFirewallStates[$key] = $firewallEnabled; + } + } catch (\Exception $e) { + $this->networkAdapters = []; + $this->adapterFirewallStates = []; + } + } + + public function toggleAdapterFirewall(string $adapterName): void + { + $this->loading = true; + try { + $proxmox = new Proxmox; + $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; + + if (!isset($config[$adapterName])) { + $this->error = 'Network adapter not found: ' . $adapterName; + $this->showErrorModal = true; + $this->loading = false; + return; + } + + $currentConfig = (string) $config[$adapterName]; + $segments = array_filter(array_map('trim', explode(',', $currentConfig))); + $newSegments = []; + $firewallFound = false; + $currentFirewallState = $this->adapterFirewallStates[$adapterName] ?? false; + $newFirewallState = !$currentFirewallState; + + foreach ($segments as $segment) { + if (!str_contains($segment, '=')) { + $newSegments[] = $segment; + continue; + } + + [$segmentKey, $segmentValue] = array_map('trim', explode('=', $segment, 2)); + + if (strtolower($segmentKey) === 'firewall') { + $firewallFound = true; + if ($newFirewallState) { + $newSegments[] = 'firewall=1'; + } + // If disabling, we skip adding firewall=1, effectively removing it + } else { + $newSegments[] = $segment; + } + } + + // If firewall wasn't in the config and we're enabling it, add it + if (!$firewallFound && $newFirewallState) { + $newSegments[] = 'firewall=1'; + } + + $newConfig = implode(',', $newSegments); + + $proxmox->request( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config', + 'put', + [$adapterName => $newConfig] + ); + + $this->adapterFirewallStates[$adapterName] = $newFirewallState; + $this->notify('Firewall ' . ($newFirewallState ? 'enabled' : 'disabled') . ' for ' . $adapterName, 'success'); + $this->loadNetworkAdapters(); // Reload to ensure consistency + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + $errorData = $e->response()->json(); + if (isset($errorData['errors']) && is_array($errorData['errors']) && !empty($errorData['errors'])) { + $errorMessage = $errorData['errors'][0]['message'] ?? $errorMessage; + } elseif (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } + $this->error = 'Failed to toggle firewall for adapter: ' . $errorMessage; + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function loadFirewallLogs(): void + { + $this->logError = ''; + try { + // Proxmox API: GET /api2/json/nodes/{node}/qemu/{vmid}/firewall/log + // Documentation: https://pve.proxmox.com/pve-docs/api-viewer/#/nodes/{node}/qemu/{vmid}/firewall/log + // Returns: text/plain format with log entries, one per line + // Fetch logs in batches and collect all of them + $allRawLogs = []; + $start = 0; + $batchSize = 500; + $maxLogs = $this->logLimit; + $fetchedCount = 0; + $totalBodyLength = 0; + $lastContentType = ''; + $lastIsJson = false; + + // Keep fetching until we have all logs or reach the limit + do { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log', + 'get', + [ + 'limit' => $batchSize, + 'start' => $start + ] + ); + + if (! $response->successful()) { + $errorData = []; + try { + $errorData = $response->json(); + } catch (\Exception $e) { + // Response might not be JSON + } + $errorMsg = $errorData['message'] ?? 'HTTP ' . $response->status(); + $this->logError = 'Failed to fetch logs: ' . $errorMsg; + \Log::warning('Firewall log API request failed', [ + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + 'server_id' => $this->server->id, + 'vm_id' => $this->server->vm_id, + ]); + $this->firewallLogs = []; + return; + } + + // Proxmox firewall log endpoint can return either text/plain or JSON format + // Try to parse as JSON first, then fall back to text/plain + $body = $response->body(); + $totalBodyLength += strlen($body); + $batchRawLogs = []; + + // Check if response is JSON + $contentType = $response->header('Content-Type'); + $lastContentType = $contentType ?? ''; + $isJson = str_contains($contentType ?? '', 'json'); + $lastIsJson = $isJson; + + if ($isJson) { + try { + $jsonData = $response->json(); + // Proxmox may return logs in different formats: + // 1. { "data": [ {"n": 1, "t": "log text"}, ... ] } + // 2. { "data": [ "line1", "line2", ... ] } + // 3. { "data": "line1\nline2\n..." } + // 4. [ {"n": 1, "t": "log text"}, ... ] + + if (isset($jsonData['data'])) { + if (is_array($jsonData['data'])) { + // Handle array of log objects or strings + foreach ($jsonData['data'] as $item) { + if (is_array($item) && isset($item['t'])) { + // Proxmox format: {"n": 1, "t": "log text"} + $batchRawLogs[] = $item['t']; + } elseif (is_string($item)) { + // Plain string format + $batchRawLogs[] = $item; + } elseif (is_array($item)) { + // Other array format, try to get text from various fields + $text = $item['text'] ?? $item['message'] ?? $item['line'] ?? json_encode($item); + $batchRawLogs[] = $text; + } + } + } elseif (is_string($jsonData['data'])) { + // Multi-line string format + $lines = preg_split('/\r\n|\r|\n/', $jsonData['data']); + $batchRawLogs = array_filter($lines, static function ($line) { + return ! empty(trim($line)); + }); + } + } elseif (is_array($jsonData)) { + // Array at root level + foreach ($jsonData as $item) { + if (is_array($item) && isset($item['t'])) { + $batchRawLogs[] = $item['t']; + } elseif (is_string($item)) { + $batchRawLogs[] = $item; + } + } + } + } catch (\Exception $e) { + // Fall through to text parsing + \Log::warning('Failed to parse JSON response, falling back to text', [ + 'error' => $e->getMessage(), + 'response_sample' => substr($body, 0, 500) + ]); + } + } + + // If no JSON data or JSON parsing failed, try text/plain format + if (empty($batchRawLogs) && ! empty($body)) { + // Split by newlines (handle both \n and \r\n) + $lines = preg_split('/\r\n|\r|\n/', $body); + $batchRawLogs = array_filter($lines, static function ($line) { + return ! empty(trim($line)); + }); + } + + // Re-index array after filtering + $batchRawLogs = array_values($batchRawLogs); + + // Add this batch to all logs + $allRawLogs = array_merge($allRawLogs, $batchRawLogs); + $fetchedCount = count($batchRawLogs); + + // Move to next batch + $start += $batchSize; + + // Break if we got fewer logs than requested (meaning we've reached the end) + // or if we've reached our limit + if ($fetchedCount < $batchSize || count($allRawLogs) >= $maxLogs) { + break; + } + } while (true); + + // Limit to maxLogs + if (count($allRawLogs) > $maxLogs) { + $allRawLogs = array_slice($allRawLogs, 0, $maxLogs); + } + + $rawLogs = $allRawLogs; + + \Log::info('Firewall logs fetched from Proxmox API', [ + 'raw_count' => count($rawLogs), + 'total_body_length' => $totalBodyLength, + 'last_content_type' => $lastContentType, + 'last_is_json' => $lastIsJson, + 'batches_fetched' => ($start / $batchSize), + 'first_log_type' => ! empty($rawLogs) ? gettype($rawLogs[0]) : 'empty', + 'first_log_length' => ! empty($rawLogs) && is_string($rawLogs[0]) ? strlen($rawLogs[0]) : 'N/A', + 'sample' => ! empty($rawLogs) ? substr(is_array($rawLogs[0]) ? json_encode($rawLogs[0]) : $rawLogs[0], 0, 300) : null, + 'first_3_logs_full' => array_slice($rawLogs, 0, 3), + ]); + + $this->firewallLogs = []; + + $successfullyParsed = 0; + $skippedEmpty = 0; + $skippedInvalid = 0; + + foreach ($rawLogs as $index => $logEntry) { + $logText = ''; + + if (is_string($logEntry)) { + $logText = trim($logEntry); + } elseif (is_array($logEntry)) { + // Handle various possible array formats from Proxmox API + // Note: 't' is the text field, 'n' is the line number - prioritize 't'! + $logText = trim( + $logEntry['t'] // Proxmox format: {"n": 1, "t": "log text"} + ?? $logEntry['text'] // Alternative text field + ?? $logEntry['raw'] // Raw log field + ?? $logEntry['message'] // Message field + ?? $logEntry['log'] // Log field + ?? $logEntry['line'] // Line field + ?? json_encode($logEntry) // Last resort: encode the whole thing + ); + } + + if ($logText === '' || strlen($logText) < 10) { + $skippedEmpty++; + continue; + } + + try { + $parsed = $this->parseFirewallLog($logText); + // Check if the log was actually parsed (has at least action or source/destination) + if ($parsed !== null && ($parsed['action'] !== null || $parsed['source'] !== null || $parsed['destination'] !== null)) { + $this->firewallLogs[] = $parsed; + $successfullyParsed++; + } else { + $skippedInvalid++; + if ($index < 5) { // Log first 5 unparseable entries for debugging + \Log::warning('Firewall log could not be parsed (missing key fields)', [ + 'entry' => substr($logText, 0, 300), + 'parsed' => $parsed + ]); + } + } + } catch (\Exception $e) { + $skippedInvalid++; + if ($index < 5) { // Log first 5 errors for debugging + \Log::warning('Failed to parse firewall log entry', [ + 'entry' => substr($logText, 0, 300), + 'error' => $e->getMessage() + ]); + } + } + } + + \Log::info('Firewall log parsing summary', [ + 'total_raw_logs' => count($rawLogs), + 'successfully_parsed' => $successfullyParsed, + 'skipped_empty' => $skippedEmpty, + 'skipped_invalid' => $skippedInvalid + ]); + + \Log::info('Firewall logs final count', [ + 'total_logs' => count($this->firewallLogs), + 'first_3_sources' => array_slice(array_column($this->firewallLogs, 'source'), 0, 3), + ]); + + if (empty($this->firewallLogs) && ! empty($rawLogs)) { + $this->logError = 'Logs received (' . count($rawLogs) . ') but could not be processed. Successfully parsed: ' . $successfullyParsed . ', Skipped invalid: ' . $skippedInvalid . '. Check Laravel logs for details.'; + } elseif (empty($this->firewallLogs)) { + $this->logError = 'No firewall logs available. Ensure firewall logging is enabled in Proxmox (Datacenter > Firewall > Options).'; + } + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + if (method_exists($e, 'response') && $e->response()) { + try { + $errorData = $e->response()->json(); + if (isset($errorData['message'])) { + $errorMessage = $errorData['message']; + } + } catch (\Exception $jsonError) { + // Response might not be JSON + } + } + $this->logError = 'Error loading logs: ' . $errorMessage; + \Log::error('Failed to load firewall logs', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'server_id' => $this->server->id, + 'vm_id' => $this->server->vm_id, + ]); + $this->firewallLogs = []; + } + } + + protected function parseFirewallLog(string $logText): array + { + $result = [ + 'raw' => $logText, + 'timestamp_display' => null, + 'timestamp_iso' => null, + 'action' => null, + 'action_badge_class' => 'bg-base/20 text-base/70', + 'level_numeric' => null, + 'level_label' => null, + 'level_badge_class' => 'bg-base/20 text-base/70', + 'protocol' => null, + 'source' => null, + 'source_port' => null, + 'destination' => null, + 'destination_port' => null, + 'iface' => null, + ]; + + // Extract level and interface (first tokens) + if (preg_match('/^\S+\s+(\d+)\s+([^\s]+)/', $logText, $matches)) { + $levelNumeric = (int) $matches[1]; + $iface = $matches[2]; + $priMap = [ + 0 => 'emerg', + 1 => 'alert', + 2 => 'crit', + 3 => 'err', + 4 => 'warning', + 5 => 'notice', + 6 => 'info', + 7 => 'debug', + ]; + $levelLabel = $priMap[$levelNumeric] ?? 'info'; + $levelBadgeMap = [ + 'emerg' => 'bg-error text-white', + 'alert' => 'bg-error/80 text-white', + 'crit' => 'bg-error/60 text-white', + 'err' => 'bg-error/20 text-error', + 'warning' => 'bg-warning/20 text-warning', + 'notice' => 'bg-primary/20 text-primary', + 'info' => 'bg-primary/20 text-primary', + 'debug' => 'bg-base/20 text-base/70', + ]; + + $result['level_numeric'] = $levelNumeric; + $result['level_label'] = strtoupper($levelLabel); + $result['level_badge_class'] = $levelBadgeMap[$levelLabel] ?? 'bg-base/20 text-base/70'; + $result['iface'] = $iface; + } + + // Extract timestamp and timezone + if (preg_match('/(\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2})\s+([+-]\d{4})/', $logText, $matches)) { + $timestamp = $matches[1]; + $tz = $matches[2]; + $tzNormalized = substr($tz, 0, 3) . ':' . substr($tz, 3); + try { + $carbon = Carbon::createFromFormat('d/M/Y:H:i:s P', $timestamp . ' ' . $tzNormalized); + $result['timestamp_iso'] = $carbon->toIso8601String(); + $result['timestamp_display'] = $carbon->format('Y-m-d H:i:s T'); + } catch (\Exception $e) { + $result['timestamp_display'] = str_replace('/', '-', $timestamp) . ' ' . $tzNormalized; + } + } + + // Extract action (REJECT, ACCEPT, DROP) + if (preg_match('/\b(REJECT|ACCEPT|DROP):/i', $logText, $matches)) { + $action = strtoupper($matches[1]); + $actionBadgeMap = [ + 'ACCEPT' => 'bg-success/20 text-success', + 'DROP' => 'bg-error/20 text-error', + 'REJECT' => 'bg-warning/20 text-warning', + ]; + $result['action'] = $action; + $result['action_badge_class'] = $actionBadgeMap[$action] ?? 'bg-base/20 text-base/70'; + } + + // Extract protocol + if (preg_match('/PROTO=([^\s]+)/', $logText, $matches)) { + $result['protocol'] = strtoupper($matches[1]); + } + + // Extract source/destination IPs and ports + if (preg_match('/SRC=([^\s]+)/', $logText, $matches)) { + $result['source'] = $matches[1]; + } + + if (preg_match('/DST=([^\s]+)/', $logText, $matches)) { + $result['destination'] = $matches[1]; + } + + if (preg_match('/SPT=(\d+)/', $logText, $matches)) { + $result['source_port'] = $matches[1]; + } + + if (preg_match('/DPT=(\d+)/', $logText, $matches)) { + $result['destination_port'] = $matches[1]; + } + + return $result; + } + + /** + * Fetch IP location data from ip-api.com + */ + public function fetchIPLocation(string $ip): array + { + try { + // Validate IP address + 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' + ]; + } + + // Fetch location data from ip-api.com (HTTP endpoint) + $response = Http::timeout(5)->get("http://ip-api.com/json/{$ip}"); + + if ($response->successful()) { + $data = $response->json(); + + // Check if the API returned success + if (isset($data['status']) && $data['status'] === 'success') { + return $data; + } else { + 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() + ]; + } + } + + public function render() + { + return view('proxmox::firewall')->layoutData([ + 'server' => $this->server, + 'status' => $this->status, + 'sidebar' => true, + ]); + } +} diff --git a/extensions/Servers/Proxmox/views/firewall-new.blade.php b/extensions/Servers/Proxmox/views/firewall-new.blade.php new file mode 100644 index 000000000..7a0ff819d --- /dev/null +++ b/extensions/Servers/Proxmox/views/firewall-new.blade.php @@ -0,0 +1,1298 @@ +
Configure firewall rules, policies, aliases, and IPSets for your virtual machine
+{{ $error }}
++ @if($firewallEnabled) + Active and protecting your virtual machine + @else + Currently disabled - your VM is not protected + @endif +
+Control default traffic behavior
+Default action for incoming traffic not matching any rule
+Default action for outgoing traffic not matching any rule
+Per-adapter firewall control
+{{ $adapter['mac'] }}
+ @endif
+ No network adapters found
+How default policies control your network traffic
+Controls what happens to traffic coming INTO your VM when it doesn't match any explicit firewall rule.
+Controls what happens to traffic going OUT of your VM when it doesn't match any explicit firewall rule.
+To create a strict whitelist (only allow specific traffic):
+IP Filter: Validates that source/destination IP addresses in packets match the VM's assigned IPs. Helps prevent IP spoofing.
+MAC Filter: Validates that MAC addresses match the VM's network adapters. Provides additional layer of security.
+| Pos | +Action | +Type | +Source | +Destination | +Protocol | +Port | +Status | +Actions | +
|---|---|---|---|---|---|---|---|---|
| {{ $rule['pos'] ?? '-' }} | ++ @php + $actionClass = match($rule['action'] ?? '') { + 'ACCEPT' => 'bg-success/20 text-success', + 'REJECT' => 'bg-warning/20 text-warning', + 'DROP' => 'bg-error/20 text-error', + default => 'bg-base/20 text-base/60' + }; + @endphp + + {{ $rule['action'] ?? '-' }} + + | ++ + {{ strtoupper($rule['type'] ?? 'IN') }} + + | +{{ $rule['source'] ?? '-' }} | +{{ $rule['dest'] ?? '-' }} | +{{ strtoupper($rule['proto'] ?? ($rule['macro'] ?? 'ANY')) }} | +{{ $rule['dport'] ?? $rule['sport'] ?? '-' }} | ++ @if(isset($rule['enable']) && $rule['enable'] == 0) + Disabled + @else + Enabled + @endif + | +
+
+
+
+
+
+ |
+
Create your first rule to control network traffic
+ +Create named references to IP addresses or CIDR ranges
+Create aliases to simplify rule management
+ +Group multiple IPs/CIDRs for easier rule management
+No IP Sets created yet
+No members in this IPSet
+ +Select an IP Set to view its members
+{{ $logError }}
+| Time | +Action | +Source | +Destination | +Protocol | +Port | +|
|---|---|---|---|---|---|---|
| {{ $log['timestamp'] ?? '-' }} | ++ + {{ $log['action'] ?? '-' }} + + | +
+
+ {{ $log['source'] ?? '-' }}
+ @php $sourceIP = $log['source'] ?? ''; @endphp
+ @if($sourceIP && $sourceIP !== '-' && $this->checkIsPublicIP($sourceIP))
+
+
+
+
+ {{-- IP Location Modal --}}
+
+
+ @endif
+ |
+
+
+ {{ $log['destination'] ?? '-' }}
+ @php $destIP = $log['destination'] ?? ''; @endphp
+ @if($destIP && $destIP !== '-' && $this->checkIsPublicIP($destIP))
+
+
+
+
+ {{-- IP Location Modal --}}
+
+
+ @endif
+ |
+
+
+ {{ $log['destination'] ?? '-' }}
+ @php $destIP = $log['destination'] ?? ''; @endphp
+ @if($destIP && $destIP !== '-' && $this->checkIsPublicIP($destIP))
+
+
+
+
+ {{-- IP Location Modal --}}
+
+
+ @endif
+ |
+ {{ $log['protocol'] ?? '-' }} | +{{ $log['destination_port'] ?? '-' }} | +
Logs will appear here when the firewall blocks or allows traffic
+Enable the firewall to start protecting your virtual machine
+Select a macro or leave as "None" for custom rules
+Can use alias names or leave empty for any source
+Are you sure you want to delete this rule? This action cannot be undone.
+Delete alias "{{ $deletingAliasName }}"?
+This may affect firewall rules using this alias.
+After creating the IPSet, you can add IP addresses and CIDR ranges to it.
+Delete IPSet "{{ $deletingIPSetName }}"?
+All members will be removed and rules using this IPSet may be affected.
+Remove "{{ $deletingMemberCidr }}" from {{ $selectedIPSetName }}?
+