diff --git a/extensions/Servers/Proxmox/Livewire/FirewallNew.php b/extensions/Servers/Proxmox/Livewire/FirewallNew.php new file mode 100644 index 000000000..1987f76bb --- /dev/null +++ b/extensions/Servers/Proxmox/Livewire/FirewallNew.php @@ -0,0 +1,1279 @@ + '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; + + // 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->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 IPv4' + ]); + } + + if ($this->server->primaryIpv6) { + $allIps->push([ + 'ip' => $this->server->primaryIpv6->ip, + 'type' => 'IPv6', + 'label' => 'Primary IPv6' + ]); + } + + foreach ($this->server->ipAddresses as $ipAddress) { + $allIps->push([ + 'ip' => $ipAddress->ip, + 'type' => strtoupper($ipAddress->type), + 'label' => 'Additional' + ]); + } + + $this->availableIps = $allIps->unique('ip')->values()->toArray(); + } + + // ==================== FIREWALL API REQUEST ==================== + + protected function firewallRequest(string $url, string $method = 'get', array $data = []): \Illuminate\Http\Client\Response + { + $baseUrl = 'https://pve.virtnet.bond'; + $user = 'root@pam'; + $tokenId = 'firewall'; + $secret = '0a439967-0a44-4b94-8906-c36b3a40cf68'; + + $fullUrl = rtrim($baseUrl, '/') . '/api2/json' . $url; + $authHeader = 'PVEAPIToken=' . $user . '!' . $tokenId . '=' . $secret; + + $acceptHeader = str_contains($url, '/firewall/log') + ? 'application/json, text/plain, */*' + : 'application/json'; + + $request = Http::withHeaders([ + 'Authorization' => $authHeader, + 'Accept' => $acceptHeader, + ])->withoutVerifying(); + + $response = match($method) { + 'post' => $request->post($fullUrl, $data), + 'put' => $request->put($fullUrl, $data), + 'delete' => $request->delete($fullUrl), + default => $request->get($fullUrl, $data), + }; + + return $response; + } + + // ==================== NETWORK ADAPTERS ==================== + + protected function loadNetworkAdapters(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' + ); + + if ($response->successful()) { + $config = $response->json()['data'] ?? []; + $adapters = []; + + foreach ($config as $key => $value) { + if (preg_match('/^net(\d+)$/', $key, $matches)) { + $parts = explode(',', $value); + $model = explode('=', $parts[0])[0] ?? 'unknown'; + $mac = null; + + 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' + ); + + 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; + } + } catch (\Exception $e) { + Log::error('Failed to load firewall options: ' . $e->getMessage()); + } + } + + public function toggleFirewall(): void + { + $this->loading = true; + + try { + $newState = !$this->firewallEnabled; + + // Always force DHCP and NDP (router advertisements) to 0 for security + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options', + 'put', + [ + 'enable' => $newState ? 1 : 0, + 'dhcp' => 0, // Always disabled + 'ndp' => 0, // Always disabled (prevents router advertisements) + ] + ); + + if ($response->successful()) { + $this->firewallEnabled = $newState; + $this->notify('Firewall ' . ($newState ? 'enabled' : 'disabled'), 'success'); + } else { + $this->error = 'Failed to toggle firewall'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to toggle firewall: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function updatePolicies(): void + { + $this->loading = true; + + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options', + 'put', + [ + 'policy_in' => $this->policyIn, + 'policy_out' => $this->policyOut, + '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' + ); + + if ($response->successful()) { + $this->rules = $response->json()['data'] ?? []; + } + } catch (\Exception $e) { + Log::error('Failed to load firewall rules: ' . $e->getMessage()); + } + } + + public function startCreateRule(): void + { + $this->resetRuleForm(); + + // Set default destination to first IP + if (!empty($this->availableIps)) { + $this->ruleDest = $this->availableIps[0]['ip']; + } + + // Set default interface to first adapter + if (!empty($this->networkAdapters)) { + $this->ruleIface = $this->networkAdapters[0]['name']; + } + + $this->editingRuleIndex = null; + $this->showRuleForm = true; + } + + public function startEditRule(int $index): void + { + if (!isset($this->rules[$index])) { + $this->error = 'Rule not found'; + $this->showErrorModal = true; + return; + } + + $rule = $this->rules[$index]; + $this->editingRuleIndex = $index; + $this->showRuleForm = true; + + $this->ruleAction = $rule['action'] ?? 'ACCEPT'; + $this->ruleType = $rule['type'] ?? 'in'; + $this->ruleSource = $rule['source'] ?? ''; + $this->ruleDest = $rule['dest'] ?? ''; + $this->ruleProto = $rule['proto'] ?? ''; + $this->ruleDport = $rule['dport'] ?? ''; + $this->ruleSport = $rule['sport'] ?? ''; + $this->ruleComment = $rule['comment'] ?? ''; + $this->ruleEnable = !isset($rule['enable']) || $rule['enable'] != 0; + $this->ruleMacro = $rule['macro'] ?? ''; + $this->ruleIface = $rule['iface'] ?? ''; + $this->ruleLog = $rule['log'] ?? 'nolog'; + } + + public function saveRule(): void + { + $this->loading = true; + + try { + $payload = [ + 'action' => $this->ruleAction, + 'type' => $this->ruleType, + 'enable' => $this->ruleEnable ? 1 : 0, + ]; + + if (!empty(trim($this->ruleMacro))) { + $payload['macro'] = trim($this->ruleMacro); + } + + if (!empty(trim($this->ruleIface))) { + $payload['iface'] = trim($this->ruleIface); + } + + if (!empty(trim($this->ruleSource))) { + $payload['source'] = trim($this->ruleSource); + } + + if (!empty(trim($this->ruleDest))) { + $payload['dest'] = trim($this->ruleDest); + } + + if (!empty(trim($this->ruleProto))) { + $payload['proto'] = trim($this->ruleProto); + } + + if (!empty(trim($this->ruleDport))) { + $payload['dport'] = trim($this->ruleDport); + } + + if (!empty(trim($this->ruleSport))) { + $payload['sport'] = trim($this->ruleSport); + } + + if (!empty(trim($this->ruleComment))) { + $payload['comment'] = trim($this->ruleComment); + } + + if (!empty(trim($this->ruleLog)) && $this->ruleLog !== 'nolog') { + $payload['log'] = $this->ruleLog; + } + + if ($this->editingRuleIndex !== null && isset($this->rules[$this->editingRuleIndex])) { + $pos = $this->rules[$this->editingRuleIndex]['pos']; + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos, + 'put', + $payload + ); + } else { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules', + 'post', + $payload + ); + } + + if ($response->successful()) { + $this->notify('Rule ' . ($this->editingRuleIndex !== null ? 'updated' : 'created') . ' successfully', 'success'); + $this->resetRuleForm(); + $this->editingRuleIndex = null; + $this->showRuleForm = false; + $this->loadFirewallRules(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to save rule: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to save rule: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function toggleRule(int $index): void + { + try { + if (!isset($this->rules[$index])) { + return; + } + + $rule = $this->rules[$index]; + $pos = $rule['pos']; + $currentEnable = isset($rule['enable']) ? (int)$rule['enable'] : 1; + $newEnable = $currentEnable == 0 ? 1 : 0; + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos, + 'put', + ['enable' => $newEnable] + ); + + if ($response->successful()) { + $this->notify('Rule ' . ($newEnable == 1 ? 'enabled' : 'disabled'), 'success'); + $this->loadFirewallRules(); + } else { + $this->error = 'Failed to toggle rule'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to toggle rule: ' . $e->getMessage(); + $this->showErrorModal = true; + } + } + + public function confirmDeleteRule(int $index): void + { + if (!isset($this->rules[$index])) { + return; + } + + $this->deletingRuleIndex = $index; + $this->showDeleteRuleModal = true; + } + + public function deleteRule(): void + { + if ($this->deletingRuleIndex === null || !isset($this->rules[$this->deletingRuleIndex])) { + return; + } + + $rule = $this->rules[$this->deletingRuleIndex]; + $pos = $rule['pos']; + $this->loading = true; + $this->showDeleteRuleModal = false; + + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $pos, + 'delete' + ); + + if ($response->successful()) { + $this->notify('Rule deleted successfully', 'success'); + $this->loadFirewallRules(); + } else { + $this->error = 'Failed to delete rule'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to delete rule: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + $this->deletingRuleIndex = null; + } + } + + public function cancelDeleteRule(): void + { + $this->deletingRuleIndex = null; + $this->showDeleteRuleModal = false; + } + + public function reorderRules(int $fromPos, int $toPos): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $fromPos, + 'put', + ['moveto' => $toPos] + ); + + if ($response->successful()) { + $this->notify('Rules reordered successfully', 'success'); + $this->loadFirewallRules(); + } else { + $this->error = 'Failed to reorder rules'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to reorder rules: ' . $e->getMessage(); + $this->showErrorModal = true; + } + } + + protected function resetRuleForm(): void + { + $this->ruleAction = 'ACCEPT'; + $this->ruleType = 'in'; + $this->ruleSource = ''; + $this->ruleDest = ''; + $this->ruleProto = ''; + $this->ruleDport = ''; + $this->ruleSport = ''; + $this->ruleComment = ''; + $this->ruleEnable = true; + $this->ruleMacro = ''; + $this->ruleIface = ''; + $this->ruleLog = 'nolog'; + } + + public function cancelRuleForm(): void + { + $this->resetRuleForm(); + $this->editingRuleIndex = null; + $this->showRuleForm = false; + } + + // ==================== ALIASES ==================== + + protected function loadAliases(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases' + ); + + if ($response->successful()) { + $this->aliases = $response->json()['data'] ?? []; + } + } catch (\Exception $e) { + Log::error('Failed to load aliases: ' . $e->getMessage()); + } + } + + public function startCreateAlias(): void + { + $this->resetAliasForm(); + $this->editingAliasName = null; + $this->showAliasForm = true; + } + + public function startEditAlias(string $name): void + { + $alias = collect($this->aliases)->firstWhere('name', $name); + + if ($alias) { + $this->editingAliasName = $name; + $this->aliasName = $alias['name']; + $this->aliasCidr = $alias['cidr']; + $this->aliasComment = $alias['comment'] ?? ''; + $this->showAliasForm = true; + } + } + + public function saveAlias(): void + { + $this->validate([ + 'aliasName' => 'required|string|max:64', + 'aliasCidr' => 'required|string', + ]); + + $this->loading = true; + + try { + $payload = [ + 'name' => trim($this->aliasName), + 'cidr' => trim($this->aliasCidr), + ]; + + if (!empty(trim($this->aliasComment))) { + $payload['comment'] = trim($this->aliasComment); + } + + if ($this->editingAliasName) { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases/' . $this->editingAliasName, + 'put', + $payload + ); + } else { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases', + 'post', + $payload + ); + } + + if ($response->successful()) { + $this->notify('Alias ' . ($this->editingAliasName ? 'updated' : 'created') . ' successfully', 'success'); + $this->resetAliasForm(); + $this->editingAliasName = null; + $this->showAliasForm = false; + $this->loadAliases(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to save alias: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to save alias: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function confirmDeleteAlias(string $name): void + { + $this->deletingAliasName = $name; + $this->showDeleteAliasModal = true; + } + + public function deleteAlias(): void + { + if (!$this->deletingAliasName) { + return; + } + + $this->loading = true; + $this->showDeleteAliasModal = false; + + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/aliases/' . $this->deletingAliasName, + 'delete' + ); + + if ($response->successful()) { + $this->notify('Alias deleted successfully', 'success'); + $this->loadAliases(); + } else { + $this->error = 'Failed to delete alias'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to delete alias: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + $this->deletingAliasName = null; + } + } + + public function cancelDeleteAlias(): void + { + $this->deletingAliasName = null; + $this->showDeleteAliasModal = false; + } + + protected function resetAliasForm(): void + { + $this->aliasName = ''; + $this->aliasCidr = ''; + $this->aliasComment = ''; + } + + public function cancelAliasForm(): void + { + $this->resetAliasForm(); + $this->editingAliasName = null; + $this->showAliasForm = false; + } + + // ==================== IPSETS ==================== + + protected function loadIPSets(): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset' + ); + + if ($response->successful()) { + $this->ipsets = $response->json()['data'] ?? []; + } + } catch (\Exception $e) { + Log::error('Failed to load IPSets: ' . $e->getMessage()); + } + } + + public function startCreateIPSet(): void + { + $this->resetIPSetForm(); + $this->editingIPSetName = null; + $this->showIPSetForm = true; + } + + public function saveIPSet(): void + { + $this->validate([ + 'ipsetName' => 'required|string|max:64', + ]); + + $this->loading = true; + + try { + $payload = [ + 'name' => trim($this->ipsetName), + ]; + + if (!empty(trim($this->ipsetComment))) { + $payload['comment'] = trim($this->ipsetComment); + } + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset', + 'post', + $payload + ); + + if ($response->successful()) { + $this->notify('IPSet created successfully', 'success'); + $this->resetIPSetForm(); + $this->showIPSetForm = false; + $this->loadIPSets(); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to create IPSet: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to create IPSet: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function confirmDeleteIPSet(string $name): void + { + $this->deletingIPSetName = $name; + $this->showDeleteIPSetModal = true; + } + + public function deleteIPSet(): void + { + if (!$this->deletingIPSetName) { + return; + } + + $this->loading = true; + $this->showDeleteIPSetModal = false; + + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->deletingIPSetName, + 'delete' + ); + + if ($response->successful()) { + $this->notify('IPSet deleted successfully', 'success'); + $this->loadIPSets(); + + // Clear selected IPSet if it was deleted + if ($this->selectedIPSetName === $this->deletingIPSetName) { + $this->selectedIPSetName = null; + $this->ipsetMembers = []; + } + } else { + $this->error = 'Failed to delete IPSet'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to delete IPSet: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + $this->deletingIPSetName = null; + } + } + + public function cancelDeleteIPSet(): void + { + $this->deletingIPSetName = null; + $this->showDeleteIPSetModal = false; + } + + protected function resetIPSetForm(): void + { + $this->ipsetName = ''; + $this->ipsetComment = ''; + } + + public function cancelIPSetForm(): void + { + $this->resetIPSetForm(); + $this->editingIPSetName = null; + $this->showIPSetForm = false; + } + + // ==================== IPSET MEMBERS ==================== + + public function selectIPSet(string $name): void + { + $this->selectedIPSetName = $name; + $this->loadIPSetMembers($name); + } + + protected function loadIPSetMembers(string $name): void + { + try { + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $name + ); + + if ($response->successful()) { + $this->ipsetMembers = $response->json()['data'] ?? []; + } + } catch (\Exception $e) { + Log::error('Failed to load IPSet members: ' . $e->getMessage()); + } + } + + public function startAddIPSetMember(): void + { + $this->memberCidr = ''; + $this->memberComment = ''; + $this->memberNomatch = false; + $this->showIPSetMemberForm = true; + } + + public function saveIPSetMember(): void + { + if (!$this->selectedIPSetName) { + return; + } + + $this->validate([ + 'memberCidr' => 'required|string', + ]); + + $this->loading = true; + + try { + $payload = [ + 'cidr' => trim($this->memberCidr), + ]; + + if (!empty(trim($this->memberComment))) { + $payload['comment'] = trim($this->memberComment); + } + + if ($this->memberNomatch) { + $payload['nomatch'] = 1; + } + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->selectedIPSetName, + 'post', + $payload + ); + + if ($response->successful()) { + $this->notify('IP/CIDR added to IPSet successfully', 'success'); + $this->memberCidr = ''; + $this->memberComment = ''; + $this->memberNomatch = false; + $this->showIPSetMemberForm = false; + $this->loadIPSetMembers($this->selectedIPSetName); + } else { + $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; + $this->error = 'Failed to add member: ' . $message; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to add member: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + public function confirmDeleteIPSetMember(string $cidr): void + { + $this->deletingMemberCidr = $cidr; + $this->showDeleteIPSetMemberModal = true; + } + + public function deleteIPSetMember(): void + { + if (!$this->selectedIPSetName || !$this->deletingMemberCidr) { + return; + } + + $this->loading = true; + $this->showDeleteIPSetMemberModal = false; + + try { + // URL encode the CIDR for the DELETE request + $encodedCidr = urlencode($this->deletingMemberCidr); + + $response = $this->firewallRequest( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/ipset/' . $this->selectedIPSetName . '/' . $encodedCidr, + 'delete' + ); + + if ($response->successful()) { + $this->notify('IP/CIDR removed from IPSet successfully', 'success'); + $this->loadIPSetMembers($this->selectedIPSetName); + } else { + $this->error = 'Failed to remove member'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + $this->error = 'Failed to remove member: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + $this->deletingMemberCidr = null; + } + } + + public function cancelDeleteIPSetMember(): void + { + $this->deletingMemberCidr = null; + $this->showDeleteIPSetMemberModal = false; + } + + public function cancelIPSetMemberForm(): void + { + $this->memberCidr = ''; + $this->memberComment = ''; + $this->memberNomatch = false; + $this->showIPSetMemberForm = false; + } + + 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], + ]; + + // Parse key-value pairs + $kvString = $matches[6]; + if (preg_match_all('/(\w+)=([^\s]+)/', $kvString, $kvMatches, PREG_SET_ORDER)) { + foreach ($kvMatches as $match) { + $key = strtolower($match[1]); + $value = $match[2]; + $fields[$key] = $value; + } + } + + // Map fields to standardized names + 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'] ?? '-', + ]; + } + + 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 { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return [ + 'status' => 'fail', + 'message' => 'Invalid or private IP address' + ]; + } + + $response = Http::timeout(5)->get("http://ip-api.com/json/{$ip}"); + + if ($response->successful()) { + $data = $response->json(); + + if (isset($data['status']) && $data['status'] === 'success') { + return $data; + } + + return [ + 'status' => 'fail', + 'message' => $data['message'] ?? 'Unable to fetch location data' + ]; + } + + return [ + 'status' => 'fail', + 'message' => 'Failed to fetch location data (HTTP ' . $response->status() . ')' + ]; + } catch (\Exception $e) { + return [ + 'status' => 'fail', + 'message' => 'Error: ' . $e->getMessage() + ]; + } + } + + // ==================== HELPER METHODS ==================== + + public function checkIsPublicIP(string $ip): bool + { + if (empty($ip) || $ip === '—' || $ip === '-') { + return false; + } + + // Use PHP's filter_var with appropriate flags + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + } + + public function render() + { + return view('proxmox::firewall-new'); + } +} + diff --git a/extensions/Servers/Proxmox/views/firewall.blade.php b/extensions/Servers/Proxmox/views/firewall.blade.php index 7a0ff819d..37a9b5f61 100644 --- a/extensions/Servers/Proxmox/views/firewall.blade.php +++ b/extensions/Servers/Proxmox/views/firewall.blade.php @@ -333,8 +333,246 @@ {{ strtoupper($rule['type'] ?? 'IN') }} - {{ $rule['source'] ?? '-' }} - {{ $rule['dest'] ?? '-' }} + +
+ {{ $rule['source'] ?? '-' }} + @php $ruleSourceIP = $rule['source'] ?? ''; @endphp + @if($ruleSourceIP && $ruleSourceIP !== '-' && $this->checkIsPublicIP($ruleSourceIP)) +
+ + + {{-- IP Location Modal (same as logs) --}} + +
+ @endif +
+ + +
+ {{ $rule['dest'] ?? '-' }} + @php $ruleDestIP = $rule['dest'] ?? ''; @endphp + @if($ruleDestIP && $ruleDestIP !== '-' && $this->checkIsPublicIP($ruleDestIP)) +
+ + + {{-- IP Location Modal (same structure) --}} + +
+ @endif +
+ + +
+ {{ $rule['dest'] ?? '-' }} + @php $ruleDestIP = $rule['dest'] ?? ''; @endphp + @if($ruleDestIP && $ruleDestIP !== '-' && $this->checkIsPublicIP($ruleDestIP)) +
+ + + {{-- IP Location Modal (same structure) --}} + +
+ @endif +
+ {{ strtoupper($rule['proto'] ?? ($rule['macro'] ?? 'ANY')) }} {{ $rule['dport'] ?? $rule['sport'] ?? '-' }}