diff --git a/extensions/Servers/Proxmox/Livewire/Firewall.php b/extensions/Servers/Proxmox/Livewire/Firewall.php index add3232df..dfc1a9343 100644 --- a/extensions/Servers/Proxmox/Livewire/Firewall.php +++ b/extensions/Servers/Proxmox/Livewire/Firewall.php @@ -62,6 +62,10 @@ class Firewall extends Component public ?FirewallConfiguration $applyingConfiguration = null; public array $applyCompatibilityWarnings = []; + // Apply to All Servers + public bool $showApplyToAllModal = false; + public array $applyToAllResults = []; + // Available Proxmox Macros public array $availableMacros = [ '' => 'None', @@ -2288,6 +2292,329 @@ class Firewall extends Component } } + // ==================== APPLY TO ALL SERVERS ==================== + + /** + * Get the current configuration as a normalized array for comparison. + */ + protected function getCurrentConfigurationData(): array + { + return [ + 'rules' => $this->rules, + 'policyIn' => $this->policyIn, + 'policyOut' => $this->policyOut, + ]; + } + + /** + * Check if the current configuration matches a saved configuration. + */ + public function currentConfigMatchesSaved(): bool + { + $currentConfig = $this->getCurrentConfigurationData(); + + foreach ($this->savedConfigurations as $saved) { + if ($this->configurationsMatch($currentConfig, [ + 'rules' => $saved['firewall_rules'] ?? [], + 'policyIn' => $saved['incoming_policy'] ?? 'DROP', + 'policyOut' => $saved['outgoing_policy'] ?? 'ACCEPT', + ])) { + return true; + } + } + + return false; + } + + /** + * Get the saved configuration that matches the current configuration, if any. + */ + public function getMatchingSavedConfiguration(): ?array + { + $currentConfig = $this->getCurrentConfigurationData(); + + foreach ($this->savedConfigurations as $saved) { + if ($this->configurationsMatch($currentConfig, [ + 'rules' => $saved['firewall_rules'] ?? [], + 'policyIn' => $saved['incoming_policy'] ?? 'DROP', + 'policyOut' => $saved['outgoing_policy'] ?? 'ACCEPT', + ])) { + return $saved; + } + } + + return null; + } + + /** + * Compare two configurations to see if they match. + */ + protected function configurationsMatch(array $config1, array $config2): bool + { + // Compare policies + if ($config1['policyIn'] !== $config2['policyIn'] || $config1['policyOut'] !== $config2['policyOut']) { + return false; + } + + // Compare rules count + $rules1 = $config1['rules'] ?? []; + $rules2 = $config2['rules'] ?? []; + + if (count($rules1) !== count($rules2)) { + return false; + } + + // Compare each rule (ignoring position) + $normalizedRules1 = $this->normalizeRulesForComparison($rules1); + $normalizedRules2 = $this->normalizeRulesForComparison($rules2); + + return $normalizedRules1 === $normalizedRules2; + } + + /** + * Normalize rules for comparison by removing position-specific data. + */ + protected function normalizeRulesForComparison(array $rules): string + { + $normalized = []; + foreach ($rules as $rule) { + $normalizedRule = [ + 'type' => $rule['type'] ?? '', + 'action' => $rule['action'] ?? '', + 'enable' => isset($rule['enable']) ? (int)$rule['enable'] : 1, + 'source' => $rule['source'] ?? '', + 'dest' => $rule['dest'] ?? '', + 'proto' => $rule['proto'] ?? '', + 'dport' => $rule['dport'] ?? '', + 'sport' => $rule['sport'] ?? '', + 'macro' => $rule['macro'] ?? '', + 'iface' => $rule['iface'] ?? '', + 'log' => $rule['log'] ?? 'nolog', + 'comment' => $rule['comment'] ?? '', + ]; + ksort($normalizedRule); + $normalized[] = $normalizedRule; + } + + // Sort rules by their content to ensure order doesn't matter + usort($normalized, function($a, $b) { + return json_encode($a) <=> json_encode($b); + }); + + return json_encode($normalized); + } + + /** + * Get all other servers owned by the current user. + */ + public function getUserOtherServers(): array + { + $userId = Auth::id(); + if (!$userId) { + return []; + } + + return Server::where('user_id', $userId) + ->where('id', '!=', $this->server->id) + ->with(['node', 'primaryIpv4', 'primaryIpv6']) + ->get() + ->toArray(); + } + + /** + * Check if Apply to All should be enabled. + * Returns true if current config is saved AND user has other servers with different configs. + */ + public function canApplyToAll(): bool + { + // Must have a saved configuration that matches current + if (!$this->currentConfigMatchesSaved()) { + return false; + } + + // Must have other servers + $otherServers = $this->getUserOtherServers(); + if (count($otherServers) === 0) { + return false; + } + + return true; + } + + /** + * Open the Apply to All modal. + */ + public function openApplyToAllModal(): void + { + $this->applyToAllResults = []; + $this->showApplyToAllModal = true; + } + + /** + * Close the Apply to All modal. + */ + public function closeApplyToAllModal(): void + { + $this->applyToAllResults = []; + $this->showApplyToAllModal = false; + } + + /** + * Apply the current configuration to all other servers owned by the user. + */ + public function applyToAllServers(): void + { + $userId = Auth::id(); + if (!$userId) { + $this->error = 'You must be logged in to apply configurations'; + $this->showErrorModal = true; + return; + } + + // Verify we have a saved configuration + $matchingConfig = $this->getMatchingSavedConfiguration(); + if (!$matchingConfig) { + $this->error = 'Current configuration must be saved before applying to all servers'; + $this->showErrorModal = true; + return; + } + + $otherServers = $this->getUserOtherServers(); + if (count($otherServers) === 0) { + $this->error = 'No other servers found to apply configuration to'; + $this->showErrorModal = true; + return; + } + + $this->loading = true; + $results = []; + + try { + foreach ($otherServers as $serverData) { + $server = Server::with('node')->find($serverData['id']); + if (!$server || !$server->node) { + $results[] = [ + 'server_id' => $serverData['id'], + 'hostname' => $serverData['hostname'] ?? 'Unknown', + 'success' => false, + 'message' => 'Server or node not found', + ]; + continue; + } + + try { + // Apply configuration to this server + $this->applyConfigurationToServer($server, $matchingConfig); + + $results[] = [ + 'server_id' => $server->id, + 'hostname' => $server->hostname ?? 'VPS ' . $server->vm_id, + 'success' => true, + 'message' => 'Configuration applied successfully', + ]; + } catch (\Exception $e) { + Log::error("Failed to apply firewall config to server {$server->id}: " . $e->getMessage()); + $results[] = [ + 'server_id' => $server->id, + 'hostname' => $server->hostname ?? 'VPS ' . $server->vm_id, + 'success' => false, + 'message' => 'Failed: ' . $e->getMessage(), + ]; + } + } + + $this->applyToAllResults = $results; + + $successCount = collect($results)->where('success', true)->count(); + $totalCount = count($results); + + if ($successCount === $totalCount) { + $this->notify("Configuration applied to all {$successCount} servers successfully", 'success'); + } elseif ($successCount > 0) { + $this->notify("Configuration applied to {$successCount} of {$totalCount} servers", 'warning'); + } else { + $this->error = 'Failed to apply configuration to any servers'; + $this->showErrorModal = true; + } + } catch (\Exception $e) { + Log::error('Failed to apply configuration to all servers: ' . $e->getMessage()); + $this->error = 'Failed to apply configuration: ' . $e->getMessage(); + $this->showErrorModal = true; + } finally { + $this->loading = false; + } + } + + /** + * Apply a saved configuration to a specific server. + */ + protected function applyConfigurationToServer(Server $server, array $config): void + { + $savedRules = $config['firewall_rules'] ?? []; + $incomingPolicy = $config['incoming_policy'] ?? 'DROP'; + $outgoingPolicy = $config['outgoing_policy'] ?? 'ACCEPT'; + + // Step 1: Update policies + $response = $this->firewallRequest( + '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/options', + 'put', + [ + 'policy_in' => $incomingPolicy, + 'policy_out' => $outgoingPolicy, + 'dhcp' => 0, + 'ndp' => 1, + ] + ); + + if (!$response->successful()) { + throw new \Exception('Failed to update firewall policies'); + } + + // Step 2: Get existing rules and delete them + $existingRulesResponse = $this->firewallRequest( + '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules' + ); + + if ($existingRulesResponse->successful()) { + $existingRules = $existingRulesResponse->json('data') ?? []; + foreach (array_reverse($existingRules) as $rule) { + if (isset($rule['pos'])) { + $this->firewallRequest( + '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules/' . $rule['pos'], + 'delete' + ); + } + } + } + + // Step 3: Create new rules from saved configuration + if (is_array($savedRules)) { + foreach ($savedRules as $savedRule) { + $rulePayload = [ + 'type' => $savedRule['type'] ?? 'in', + 'action' => $savedRule['action'] ?? 'ACCEPT', + 'enable' => isset($savedRule['enable']) ? (int)$savedRule['enable'] : 1, + ]; + + if (!empty($savedRule['source'])) $rulePayload['source'] = $savedRule['source']; + if (!empty($savedRule['dest'])) $rulePayload['dest'] = $savedRule['dest']; + if (!empty($savedRule['proto'])) $rulePayload['proto'] = $savedRule['proto']; + if (!empty($savedRule['dport'])) $rulePayload['dport'] = $savedRule['dport']; + if (!empty($savedRule['sport'])) $rulePayload['sport'] = $savedRule['sport']; + if (!empty($savedRule['macro'])) $rulePayload['macro'] = $savedRule['macro']; + if (!empty($savedRule['comment'])) $rulePayload['comment'] = $savedRule['comment']; + if (!empty($savedRule['log']) && $savedRule['log'] !== 'nolog') $rulePayload['log'] = $savedRule['log']; + // Skip iface as it may not exist on other servers + + $this->firewallRequest( + '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/firewall/rules', + 'post', + $rulePayload + ); + } + } + } + public function render() { return view('proxmox::firewall')->layoutData([ diff --git a/extensions/Servers/Proxmox/views/firewall.blade.php b/extensions/Servers/Proxmox/views/firewall.blade.php index d210ad902..b96b94794 100644 --- a/extensions/Servers/Proxmox/views/firewall.blade.php +++ b/extensions/Servers/Proxmox/views/firewall.blade.php @@ -1402,10 +1402,26 @@

Saved Configurations

Save and reuse firewall configurations across your VPS instances

- +
+ {{-- Apply to All Button --}} + + {{-- Save Current Configuration Button --}} + +
@if(count($savedConfigurations) > 0) @@ -2236,6 +2252,123 @@ @endif +{{-- Apply to All Servers Modal --}} +@if($showApplyToAllModal) +
+
+ +
+
+ +
+
+

Apply to All Servers

+

Apply the current firewall configuration to all your other VPS instances.

+
+
+ + @php $otherServers = $this->getUserOtherServers(); @endphp + + @if(count($applyToAllResults) > 0) + {{-- Show Results --}} +
+

Results

+
+ @foreach($applyToAllResults as $result) +
+
+ @if($result['success']) + + @else + + @endif + {{ $result['hostname'] }} +
+ {{ $result['message'] }} +
+ @endforeach +
+
+ +
+ +
+ @else + {{-- Show Confirmation --}} +
+
+ +
+

Warning

+

This will replace the firewall configuration on all your other servers. Existing rules will be deleted and replaced with the current configuration.

+
+
+
+ + @if(count($otherServers) > 0) +
+

Servers to Update ({{ count($otherServers) }})

+
+ @foreach($otherServers as $otherServer) +
+
+
+ +
+
+ {{ $otherServer['hostname'] ?? 'VPS ' . $otherServer['vm_id'] }} + VM {{ $otherServer['vm_id'] }} +
+
+
+ @endforeach +
+
+ +
+

Configuration to Apply

+
+
+ Rules: + {{ count($rules) }} +
+
+ Policy In: + {{ $policyIn }} +
+
+ Policy Out: + {{ $policyOut }} +
+
+
+ @else +
+ +

No other servers found to apply the configuration to.

+
+ @endif + +
+ + @if(count($otherServers) > 0) + + @endif +
+ @endif +
+
+@endif + @script