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; } 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 $pos): void { $rule = collect($this->rules)->firstWhere('pos', $pos); if ($rule) { $this->editingRulePos = $pos; $this->showRuleForm = true; $this->ruleAction = $rule['action'] ?? 'ACCEPT'; $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'] == 1; $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) { // Update existing rule $response = $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/rules/' . $this->editingRulePos, '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 $pos): void { $this->loading = true; try { // Find the rule to get its current enable state $rule = collect($this->rules)->firstWhere('pos', $pos); if (!$rule) { $this->error = 'Rule not found'; $this->showErrorModal = true; $this->loading = false; return; } // Toggle the enable state (0 = disabled, 1 = enabled) $currentEnable = isset($rule['enable']) ? (int)$rule['enable'] : 1; $newEnable = $currentEnable == 0 ? 1 : 0; // 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'); $this->loadFirewallRules(); } else { $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; $this->error = 'Failed to toggle 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 toggle rule: ' . $errorMessage; $this->showErrorModal = true; } finally { $this->loading = false; } } public function confirmDeleteRule(int $pos): void { $this->deletingRulePos = $pos; $this->showDeleteModal = true; } public function cancelDeleteRule(): void { $this->deletingRulePos = null; $this->showDeleteModal = false; } public function deleteRule(): void { if ($this->deletingRulePos === null) { return; } $pos = $this->deletingRulePos; $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 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, ]); } }