Add "Apply to All Servers" functionality in Firewall component and view
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -1402,10 +1402,26 @@
|
||||
<h3 class="text-lg font-semibold">Saved Configurations</h3>
|
||||
<p class="text-sm text-base/60 mt-1">Save and reuse firewall configurations across your VPS instances</p>
|
||||
</div>
|
||||
<button type="button" wire:click="openSaveConfigModal" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
||||
<x-ri-save-3-line class="size-4" />
|
||||
Save Current Configuration
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
{{-- Apply to All Button --}}
|
||||
<button type="button"
|
||||
wire:click="openApplyToAllModal"
|
||||
@if(!$this->canApplyToAll()) disabled @endif
|
||||
class="px-4 py-2 bg-success text-white rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed {{ $this->canApplyToAll() ? 'hover:bg-success/90' : '' }}"
|
||||
title="{{ !$this->currentConfigMatchesSaved() ? 'Save the current configuration first' : (count($this->getUserOtherServers()) === 0 ? 'No other servers to apply to' : 'Apply to all your other servers') }}">
|
||||
<x-ri-global-line class="size-4" />
|
||||
Apply to All
|
||||
</button>
|
||||
{{-- Save Current Configuration Button --}}
|
||||
<button type="button"
|
||||
wire:click="openSaveConfigModal"
|
||||
@if($this->currentConfigMatchesSaved()) disabled @endif
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed {{ !$this->currentConfigMatchesSaved() ? 'hover:bg-primary/90' : '' }}"
|
||||
title="{{ $this->currentConfigMatchesSaved() ? 'This configuration is already saved' : 'Save the current configuration' }}">
|
||||
<x-ri-save-3-line class="size-4" />
|
||||
Save Current Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($savedConfigurations) > 0)
|
||||
@@ -2236,6 +2252,123 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Apply to All Servers Modal --}}
|
||||
@if($showApplyToAllModal)
|
||||
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-2xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||||
<button wire:click="closeApplyToAllModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="w-12 h-12 rounded-full bg-success/10 flex items-center justify-center flex-shrink-0">
|
||||
<x-ri-global-line class="size-6 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-2">Apply to All Servers</h3>
|
||||
<p class="text-sm text-base/70">Apply the current firewall configuration to all your other VPS instances.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $otherServers = $this->getUserOtherServers(); @endphp
|
||||
|
||||
@if(count($applyToAllResults) > 0)
|
||||
{{-- Show Results --}}
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold mb-3">Results</h4>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
@foreach($applyToAllResults as $result)
|
||||
<div class="flex items-center justify-between p-3 rounded-lg {{ $result['success'] ? 'bg-success/10 border border-success/20' : 'bg-error/10 border border-error/20' }}">
|
||||
<div class="flex items-center gap-3">
|
||||
@if($result['success'])
|
||||
<x-ri-checkbox-circle-fill class="size-5 text-success" />
|
||||
@else
|
||||
<x-ri-close-circle-fill class="size-5 text-error" />
|
||||
@endif
|
||||
<span class="font-medium">{{ $result['hostname'] }}</span>
|
||||
</div>
|
||||
<span class="text-xs {{ $result['success'] ? 'text-success' : 'text-error' }}">{{ $result['message'] }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button wire:click="closeApplyToAllModal" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
{{-- Show Confirmation --}}
|
||||
<div class="bg-warning/10 border border-warning/30 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-ri-alert-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 class="font-semibold text-warning mb-1">Warning</h4>
|
||||
<p class="text-sm text-base/70">This will replace the firewall configuration on all your other servers. Existing rules will be deleted and replaced with the current configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($otherServers) > 0)
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold mb-3">Servers to Update ({{ count($otherServers) }})</h4>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
@foreach($otherServers as $otherServer)
|
||||
<div class="flex items-center justify-between p-3 bg-background/50 border border-neutral/20 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<x-ri-server-line class="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">{{ $otherServer['hostname'] ?? 'VPS ' . $otherServer['vm_id'] }}</span>
|
||||
<span class="text-xs text-base/50 ml-2">VM {{ $otherServer['vm_id'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-semibold mb-2">Configuration to Apply</h4>
|
||||
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<span class="text-base/60">Rules:</span>
|
||||
<span class="font-medium ml-1">{{ count($rules) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base/60">Policy In:</span>
|
||||
<span class="font-medium ml-1 {{ $policyIn === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyIn }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base/60">Policy Out:</span>
|
||||
<span class="font-medium ml-1 {{ $policyOut === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyOut }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 text-base/60">
|
||||
<x-ri-server-line class="size-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No other servers found to apply the configuration to.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button wire:click="closeApplyToAllModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
@if(count($otherServers) > 0)
|
||||
<button wire:click="applyToAllServers" wire:loading.attr="disabled" class="px-4 py-2 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-sm font-medium disabled:opacity-50">
|
||||
<span wire:loading.remove wire:target="applyToAllServers">Apply to {{ count($otherServers) }} Server{{ count($otherServers) > 1 ? 's' : '' }}</span>
|
||||
<span wire:loading wire:target="applyToAllServers">Applying...</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@script
|
||||
<script>
|
||||
// IP location helper functions
|
||||
|
||||
Reference in New Issue
Block a user