From a4b27641c85933e2c73ab96c587b98754af3153d Mon Sep 17 00:00:00 2001 From: Jan Thiel Date: Fri, 17 Apr 2026 12:41:10 +0200 Subject: [PATCH 1/6] feat(hetzner): add endpoints and UI for attaching firewalls and internal networks to the Server Creation dialog --- .../Controllers/Api/HetznerController.php | 213 ++++++++++++++++++ app/Livewire/Server/New/ByHetzner.php | 76 +++++++ app/Services/HetznerService.php | 10 + .../livewire/server/new/by-hetzner.blade.php | 33 ++- routes/api.php | 2 + tests/Feature/HetznerApiTest.php | 94 ++++++++ tests/Feature/HetznerServerCreationTest.php | 89 ++++++++ 7 files changed, 516 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index ed91b4475..55bd4197f 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -454,6 +454,193 @@ public function sshKeys(Request $request) } } + #[OA\Get( + summary: 'Get Hetzner Firewalls', + description: 'Get all existing Hetzner firewalls for the current project.', + path: '/hetzner/firewalls', + operationId: 'get-hetzner-firewalls', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner firewalls.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function firewalls(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + + try { + $hetznerService = new HetznerService($token->token); + + return response()->json($hetznerService->getFirewalls()); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch firewalls: '.$e->getMessage()], 500); + } + } + + #[OA\Get( + summary: 'Get Hetzner Networks', + description: 'Get all existing Hetzner private networks for the current project.', + path: '/hetzner/networks', + operationId: 'get-hetzner-networks', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner networks.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'ip_range' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function networks(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + + try { + $hetznerService = new HetznerService($token->token); + + return response()->json($hetznerService->getNetworks()); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch networks: '.$e->getMessage()], 500); + } + } + #[OA\Post( summary: 'Create Hetzner Server', description: 'Create a new server on Hetzner and register it in Coolify.', @@ -482,6 +669,8 @@ public function sshKeys(Request $request) 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], + 'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'], + 'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'], 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], ], @@ -540,6 +729,8 @@ public function createServer(Request $request) 'enable_ipv4', 'enable_ipv6', 'hetzner_ssh_key_ids', + 'hetzner_firewall_ids', + 'hetzner_network_ids', 'cloud_init_script', 'instant_validate', ]; @@ -566,6 +757,10 @@ public function createServer(Request $request) 'enable_ipv6' => 'nullable|boolean', 'hetzner_ssh_key_ids' => 'nullable|array', 'hetzner_ssh_key_ids.*' => 'integer', + 'hetzner_firewall_ids' => 'nullable|array', + 'hetzner_firewall_ids.*' => 'integer', + 'hetzner_network_ids' => 'nullable|array', + 'hetzner_network_ids.*' => 'integer', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'instant_validate' => 'nullable|boolean', ]); @@ -604,6 +799,12 @@ public function createServer(Request $request) if (is_null($request->hetzner_ssh_key_ids)) { $request->offsetSet('hetzner_ssh_key_ids', []); } + if (is_null($request->hetzner_firewall_ids)) { + $request->offsetSet('hetzner_firewall_ids', []); + } + if (is_null($request->hetzner_network_ids)) { + $request->offsetSet('hetzner_network_ids', []); + } if (is_null($request->instant_validate)) { $request->offsetSet('instant_validate', false); } @@ -679,6 +880,18 @@ public function createServer(Request $request) ], ]; + $firewallIds = array_values(array_unique($request->hetzner_firewall_ids)); + if ($firewallIds !== []) { + $params['firewalls'] = array_map(function (int $firewallId): array { + return ['firewall' => $firewallId]; + }, $firewallIds); + } + + $networkIds = array_values(array_unique($request->hetzner_network_ids)); + if ($networkIds !== []) { + $params['networks'] = $networkIds; + } + // Add cloud-init script if provided if (! empty($request->cloud_init_script)) { $params['user_data'] = $request->cloud_init_script; diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 4c6f31b0c..8fb12e714 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -46,6 +46,10 @@ class ByHetzner extends Component public array $hetznerSshKeys = []; + public array $hetznerFirewalls = []; + + public array $hetznerNetworks = []; + public ?string $selected_location = null; public ?int $selected_image = null; @@ -54,6 +58,10 @@ class ByHetzner extends Component public array $selectedHetznerSshKeyIds = []; + public array $selectedHetznerFirewallIds = []; + + public array $selectedHetznerNetworkIds = []; + public string $server_name = ''; public ?int $private_key_id = null; @@ -112,6 +120,9 @@ public function resetSelection() $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; } public function loadTokens() @@ -160,6 +171,10 @@ protected function rules(): array 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, 'selectedHetznerSshKeyIds' => 'nullable|array', 'selectedHetznerSshKeyIds.*' => 'integer', + 'selectedHetznerFirewallIds' => 'nullable|array', + 'selectedHetznerFirewallIds.*' => 'integer', + 'selectedHetznerNetworkIds' => 'nullable|array', + 'selectedHetznerNetworkIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], @@ -241,6 +256,9 @@ public function previousStep() private function loadHetznerData(string $token) { $this->loading_data = true; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; try { $hetznerService = new HetznerService($token); @@ -270,6 +288,14 @@ private function loadHetznerData(string $token) ->toArray(); // Load SSH keys from Hetzner $this->hetznerSshKeys = $hetznerService->getSshKeys(); + $this->hetznerFirewalls = collect($hetznerService->getFirewalls()) + ->sortBy('name') + ->values() + ->toArray(); + $this->hetznerNetworks = collect($hetznerService->getNetworks()) + ->sortBy('name') + ->values() + ->toArray(); $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; @@ -345,6 +371,37 @@ public function getAvailableImagesProperty() return $filtered; } + public function getAvailableNetworksProperty(): array + { + $attachableNetworks = collect($this->hetznerNetworks) + ->filter(function (array $network) { + return collect($network['subnets'] ?? [])->contains(function (array $subnet) { + return in_array($subnet['type'] ?? null, ['cloud', 'server'], true); + }); + }); + + if (! $this->selected_location) { + return $attachableNetworks->values()->toArray(); + } + + $location = collect($this->locations)->firstWhere('name', $this->selected_location); + $networkZone = $location['network_zone'] ?? null; + + if (! $networkZone) { + return $attachableNetworks->values()->toArray(); + } + + return $attachableNetworks + ->filter(function (array $network) use ($networkZone) { + return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) { + return in_array($subnet['type'] ?? null, ['cloud', 'server'], true) + && ($subnet['network_zone'] ?? null) === $networkZone; + }); + }) + ->values() + ->toArray(); + } + public function getSelectedServerPriceProperty(): ?string { if (! $this->selected_server_type) { @@ -367,6 +424,13 @@ public function updatedSelectedLocation($value) // Reset server type and image when location changes $this->selected_server_type = null; $this->selected_image = null; + + $this->selectedHetznerNetworkIds = array_values(array_filter( + $this->selectedHetznerNetworkIds, + function (int $selectedNetworkId): bool { + return collect($this->availableNetworks)->contains('id', $selectedNetworkId); + } + )); } public function updatedSelectedServerType($value) @@ -454,6 +518,18 @@ private function createHetznerServer(string $token): array ], ]; + $firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds)); + if ($firewallIds !== []) { + $params['firewalls'] = array_map(function (int $firewallId): array { + return ['firewall' => $firewallId]; + }, $firewallIds); + } + + $networkIds = array_values(array_unique($this->selectedHetznerNetworkIds)); + if ($networkIds !== []) { + $params['networks'] = $networkIds; + } + // Add cloud-init script if provided if (! empty($this->cloud_init_script)) { $params['user_data'] = $this->cloud_init_script; diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 1de7eb2b1..ce6ce6497 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -117,6 +117,16 @@ public function getSshKeys(): array return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys'); } + public function getFirewalls(): array + { + return $this->requestPaginated('get', '/firewalls', 'firewalls'); + } + + public function getNetworks(): array + { + return $this->requestPaginated('get', '/networks', 'networks'); + } + public function uploadSshKey(string $name, string $publicKey): array { $response = $this->request('post', '/ssh_keys', [ diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index cb2cee06f..2efb9baeb 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -149,6 +149,37 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning- +
+ + @foreach ($hetznerFirewalls as $firewall) + + @endforeach + +
+ +
+ + @foreach ($this->availableNetworks as $network) + + @endforeach + +
+
@@ -201,4 +232,4 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning- @endif @endif @endif -
\ No newline at end of file +
diff --git a/routes/api.php b/routes/api.php index 0d3edcced..60d96410f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -94,6 +94,8 @@ Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']); Route::get('/hetzner/images', [HetznerController::class, 'images'])->middleware(['api.ability:read']); Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::get('/hetzner/firewalls', [HetznerController::class, 'firewalls'])->middleware(['api.ability:read']); + Route::get('/hetzner/networks', [HetznerController::class, 'networks'])->middleware(['api.ability:read']); Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']); Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index bd316ca49..c8546e36b 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -4,6 +4,7 @@ use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; +use Illuminate\Http\Client\Request as HttpRequest; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -210,6 +211,52 @@ }); }); +describe('GET /api/v1/hetzner/firewalls', function () { + test('gets Hetzner firewalls', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'firewalls' => [ + ['id' => 38, 'name' => 'web-firewall', 'rules' => []], + ['id' => 39, 'name' => 'ssh-firewall', 'rules' => []], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertSuccessful(); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'web-firewall']); + }); +}); + +describe('GET /api/v1/hetzner/networks', function () { + test('gets Hetzner networks', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'networks' => [ + ['id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => []], + ['id' => 457, 'name' => 'private-us', 'ip_range' => '10.1.0.0/16', 'subnets' => []], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertSuccessful(); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'private-eu']); + }); +}); + describe('POST /api/v1/servers/hetzner', function () { test('creates a Hetzner server', function () { // Mock Hetzner API calls @@ -418,6 +465,53 @@ $response->assertJsonFragment(['ip' => '2001:db8::1']); }); + test('passes selected firewalls and networks to Hetzner server creation', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 456, + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'hetzner_firewall_ids' => [38, 39], + 'hetzner_network_ids' => [456, 457, 456], + ]); + + $response->assertCreated(); + + Http::assertSent(function (HttpRequest $request): bool { + return $request->method() === 'POST' + && $request->url() === 'https://api.hetzner.cloud/v1/servers' + && $request['networks'] === [456, 457] + && $request['firewalls'] === [ + ['firewall' => 38], + ['firewall' => 39], + ]; + }); + }); + test('rejects extra fields not in allowed list', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php index 8f1a13d7a..9493ec031 100644 --- a/tests/Feature/HetznerServerCreationTest.php +++ b/tests/Feature/HetznerServerCreationTest.php @@ -1,9 +1,12 @@ and(count($sshKeys))->toBe(3); }); +it('validates network array merging removes duplicate Hetzner network ids', function () { + $selectedHetznerNetworkIds = [456, 789, 456]; + + $networkIds = array_values(array_unique($selectedHetznerNetworkIds)); + + expect($networkIds)->toBe([456, 789]) + ->and(count($networkIds))->toBe(2); +}); + describe('Boarding Flow Integration', function () { uses(RefreshDatabase::class); @@ -186,3 +198,80 @@ expect($this->team->fresh()->show_boarding)->toBeTrue(); }); }); + +describe('Hetzner data loading', function () { + uses(RefreshDatabase::class); + + beforeEach(function () { + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->hetznerToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-api-token', + ]); + + PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + }); + + test('loads firewalls and networks for the selected Hetzner token', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'server_types' => [ + ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/images*' => Http::response([ + 'images' => [ + ['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'firewalls' => [ + ['id' => 38, 'name' => 'web-firewall', 'rules' => []], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'networks' => [ + [ + 'id' => 456, + 'name' => 'private-eu', + 'ip_range' => '10.0.0.0/16', + 'subnets' => [ + ['type' => 'cloud', 'network_zone' => 'eu-central'], + ], + ], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $component = Livewire::test(ByHetzner::class) + ->set('selected_token_id', $this->hetznerToken->id) + ->call('nextStep') + ->assertSet('current_step', 2); + + expect($component->get('hetznerFirewalls'))->toHaveCount(1) + ->and($component->get('hetznerNetworks'))->toHaveCount(1); + }); +}); From 6637159d7bd8d996e9b73695f11b445ebdc50fe2 Mon Sep 17 00:00:00 2001 From: Jan Thiel Date: Tue, 28 Apr 2026 15:37:26 +0200 Subject: [PATCH 2/6] feat(hetzner): add support for enabling backups during server creation --- .../Controllers/Api/HetznerController.php | 16 +++- app/Livewire/Server/New/ByHetzner.php | 32 ++++++- app/Services/HetznerService.php | 10 ++- .../livewire/server/new/by-hetzner.blade.php | 8 +- tests/Feature/HetznerApiTest.php | 84 ++++++++++++++++++- tests/Feature/HetznerServerCreationTest.php | 8 ++ 6 files changed, 148 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 55bd4197f..d729bca54 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; use App\Http\Controllers\Controller; @@ -12,6 +13,7 @@ use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -668,6 +670,7 @@ public function networks(Request $request) 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'], 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], + 'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'], 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], 'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'], 'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'], @@ -728,6 +731,7 @@ public function createServer(Request $request) 'private_key_uuid', 'enable_ipv4', 'enable_ipv6', + 'enable_backups', 'hetzner_ssh_key_ids', 'hetzner_firewall_ids', 'hetzner_network_ids', @@ -741,7 +745,7 @@ public function createServer(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -755,6 +759,7 @@ public function createServer(Request $request) 'private_key_uuid' => 'required|string', 'enable_ipv4' => 'nullable|boolean', 'enable_ipv6' => 'nullable|boolean', + 'enable_backups' => 'nullable|boolean', 'hetzner_ssh_key_ids' => 'nullable|array', 'hetzner_ssh_key_ids.*' => 'integer', 'hetzner_firewall_ids' => 'nullable|array', @@ -796,6 +801,9 @@ public function createServer(Request $request) if (is_null($request->enable_ipv6)) { $request->offsetSet('enable_ipv6', true); } + if (is_null($request->enable_backups)) { + $request->offsetSet('enable_backups', false); + } if (is_null($request->hetzner_ssh_key_ids)) { $request->offsetSet('hetzner_ssh_key_ids', []); } @@ -900,6 +908,10 @@ public function createServer(Request $request) // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); + if ($request->enable_backups) { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } + // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -930,7 +942,7 @@ public function createServer(Request $request) // Validate server if requested if ($request->instant_validate) { - \App\Actions\Server\ValidateServer::dispatch($server); + ValidateServer::dispatch($server); } return response()->json([ diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 8fb12e714..9b021b2c3 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -72,6 +72,8 @@ class ByHetzner extends Component public bool $enable_ipv6 = true; + public bool $enable_backups = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -116,6 +118,7 @@ public function resetSelection() { $this->selected_token_id = null; $this->current_step = 1; + $this->enable_backups = false; $this->cloud_init_script = null; $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; @@ -177,6 +180,7 @@ protected function rules(): array 'selectedHetznerNetworkIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', + 'enable_backups' => 'required|boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', @@ -419,6 +423,23 @@ public function getSelectedServerPriceProperty(): ?string return '€'.number_format($price, 2); } + public function getSelectedServerBackupSurchargeProperty(): ?string + { + if (! $this->selected_server_type) { + return null; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) { + return null; + } + + $price = (float) $serverType['prices'][0]['price_monthly']['gross']; + + return '€'.number_format($price * 0.2, 2); + } + public function updatedSelectedLocation($value) { // Reset server type and image when location changes @@ -461,10 +482,8 @@ public function clearCloudInitScript() $this->save_cloud_init_script = false; } - private function createHetznerServer(string $token): array + private function createHetznerServer(HetznerService $hetznerService): array { - $hetznerService = new HetznerService($token); - // Get the private key and extract public key $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); @@ -564,9 +583,14 @@ public function submit() } $hetznerToken = $this->getHetznerToken(); + $hetznerService = new HetznerService($hetznerToken); // Create server on Hetzner - $hetznerServer = $this->createHetznerServer($hetznerToken); + $hetznerServer = $this->createHetznerServer($hetznerService); + + if ($this->enable_backups) { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index ce6ce6497..3deb23c33 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Exceptions\RateLimitException; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; class HetznerService @@ -24,7 +25,7 @@ private function request(string $method, string $endpoint, array $data = []) ->timeout(30) ->retry(3, function (int $attempt, \Exception $exception) { // Handle rate limiting (429 Too Many Requests) - if ($exception instanceof \Illuminate\Http\Client\RequestException) { + if ($exception instanceof RequestException) { $response = $exception->response; if ($response && $response->status() === 429) { @@ -153,6 +154,13 @@ public function createServer(array $params): array return $response['server'] ?? []; } + public function enableServerBackup(int $serverId): array + { + $response = $this->request('post', "/servers/{$serverId}/actions/enable_backup"); + + return $response['action'] ?? []; + } + public function getServer(int $serverId): array { $response = $this->request('get', "/servers/{$serverId}"); diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 2efb9baeb..2a25e9958 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -73,7 +73,7 @@ @if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info']) ({{ $serverType['cpu_vendor_info'] }}) @endif - , {{ $serverType['memory'] }}GB RAM, + , {{ $serverType['memory'] }}GB RAM, {{ $serverType['disk'] }}GB @if (isset($serverType['architecture'])) [{{ $serverType['architecture'] }}] @@ -190,6 +190,12 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning- +
+ + +
+
diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index c8546e36b..e0c7e35ff 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -1,6 +1,7 @@ set('cache.default', 'array'); + config()->set('app.maintenance.driver', 'file'); + config()->set('app.maintenance.store', 'array'); + + InstanceSettings::unguarded(function () { + InstanceSettings::query()->create([ + 'id' => 0, + 'is_registration_enabled' => true, + ]); + }); + // Create a team with owner $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -22,15 +34,25 @@ $this->bearerToken = $this->token->plainTextToken; // Create a Hetzner cloud provider token - $this->hetznerToken = CloudProviderToken::factory()->create([ + $this->hetznerToken = CloudProviderToken::create([ 'team_id' => $this->team->id, 'provider' => 'hetzner', + 'name' => 'Test Hetzner Token', 'token' => 'test-hetzner-api-token', ]); // Create a private key - $this->privateKey = PrivateKey::factory()->create([ + $this->privateKey = PrivateKey::create([ 'team_id' => $this->team->id, + 'name' => 'Test Key', + 'description' => 'Test SSH key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', ]); }); @@ -307,6 +329,64 @@ ]); }); + test('enables backups after creating a Hetzner server when requested', function () { + Http::fake(function (HttpRequest $request) { + if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) { + return Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') { + return Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') { + return Http::response([ + 'server' => [ + 'id' => 456, + 'name' => 'test-server', + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') { + return Http::response([ + 'action' => ['id' => 789, 'command' => 'enable_backup', 'status' => 'running'], + ], 201); + } + + return Http::response([], 404); + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => true, + 'enable_ipv6' => true, + 'enable_backups' => true, + ]); + + $response->assertStatus(201); + + Http::assertSent(fn (HttpRequest $request) => $request->method() === 'POST' + && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup'); + }); + test('generates server name if not provided', function () { Http::fake([ 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php index 9493ec031..0e4a228db 100644 --- a/tests/Feature/HetznerServerCreationTest.php +++ b/tests/Feature/HetznerServerCreationTest.php @@ -197,6 +197,14 @@ // Boarding should still be enabled since it wasn't created from onboarding expect($this->team->fresh()->show_boarding)->toBeTrue(); }); + + test('shows the backups option with the pricing note in the create dialog', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->assertSet('enable_backups', false) + ->assertSee('Enable Hetzner Backups') + ->assertSee('additional 20% of the server monthly fee'); + }); }); describe('Hetzner data loading', function () { From d723a52d9cfc0c5a009b235d9bc30a7333ff8a59 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:24:15 +0200 Subject: [PATCH 3/6] fix(hetzner): secure token-backed option fetches Authorize stored Hetzner cloud provider tokens before fetching firewalls or networks, hide provider error details, and keep server creation intact if backup activation fails. Collapse advanced Hetzner server options by default while preserving visibility when advanced values are selected. --- .../Controllers/Api/HetznerController.php | 18 ++- app/Livewire/Server/New/ByHetzner.php | 91 +++++++++++- templates/service-templates-latest.json | 60 ++++---- templates/service-templates.json | 60 ++++---- tests/Feature/Api/HetznerApiTest.php | 137 ++++++++++++++++++ .../Server/HetznerServerCreationTest.php | 49 +++++-- 6 files changed, 330 insertions(+), 85 deletions(-) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index c17a5641e..151f9854b 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -543,13 +543,14 @@ public function firewalls(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getFirewalls()); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch firewalls: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500); } } @@ -637,13 +638,14 @@ public function networks(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getNetworks()); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch networks: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500); } } @@ -914,10 +916,6 @@ public function createServer(Request $request) // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); - if ($request->enable_backups) { - $hetznerService->enableServerBackup((int) $hetznerServer['id']); - } - // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -946,6 +944,14 @@ public function createServer(Request $request) $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($request->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + // Validate server if requested if ($request->instant_validate) { ValidateServer::dispatch($server); diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 95d70fe41..0da4ef344 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -74,6 +74,10 @@ class ByHetzner extends Component public bool $enable_backups = false; + public bool $show_advanced_hetzner_options = false; + + public bool $show_cloud_init_script = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -127,6 +131,8 @@ public function resetSelection() $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; + $this->show_advanced_hetzner_options = false; + $this->show_cloud_init_script = false; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; $this->selectedHetznerNetworkIds = []; @@ -185,6 +191,8 @@ protected function rules(): array 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', 'enable_backups' => 'required|boolean', + 'show_advanced_hetzner_options' => 'boolean', + 'show_cloud_init_script' => 'boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', @@ -444,6 +452,67 @@ public function getSelectedServerBackupSurchargeProperty(): ?string return '€'.number_format($price * 0.2, 2); } + public function getShouldShowAdvancedHetznerOptionsProperty(): bool + { + return $this->show_advanced_hetzner_options + || $this->selectedHetznerSshKeyIds !== [] + || $this->selectedHetznerFirewallIds !== [] + || $this->selectedHetznerNetworkIds !== [] + || $this->enable_backups + || ! $this->enable_ipv4 + || ! $this->enable_ipv6 + || $this->show_cloud_init_script + || filled($this->cloud_init_script) + || $this->save_cloud_init_script + || filled($this->cloud_init_script_name) + || filled($this->selected_cloud_init_script_id); + } + + public function getAdvancedHetznerOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedHetznerSshKeyIds) > 0) { + $summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds)); + } + + if (count($this->selectedHetznerFirewallIds) > 0) { + $summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds)); + } + + if (count($this->selectedHetznerNetworkIds) > 0) { + $summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds)); + } + + if ($this->enable_backups) { + $summary[] = 'Backups on'; + } + + if (! $this->enable_ipv4 || ! $this->enable_ipv6) { + $summary[] = collect([ + $this->enable_ipv4 ? 'IPv4' : null, + $this->enable_ipv6 ? 'IPv6' : null, + ])->filter()->join(' + ') ?: 'No public IP'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function toggleAdvancedHetznerOptions(): void + { + $this->show_advanced_hetzner_options = ! $this->show_advanced_hetzner_options; + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + $this->show_advanced_hetzner_options = true; + } + public function updatedSelectedLocation($value) { // Reset server type and image when location changes @@ -475,6 +544,15 @@ public function updatedSelectedCloudInitScriptId($value) $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); $this->cloud_init_script = $script->script; $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + $this->show_advanced_hetzner_options = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; } } @@ -484,6 +562,7 @@ public function clearCloudInitScript() $this->cloud_init_script = ''; $this->cloud_init_script_name = ''; $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; } private function createHetznerServer(HetznerService $hetznerService): array @@ -592,10 +671,6 @@ public function submit() // Create server on Hetzner $hetznerServer = $this->createHetznerServer($hetznerService); - if ($this->enable_backups) { - $hetznerService->enableServerBackup((int) $hetznerServer['id']); - } - // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -624,6 +699,14 @@ public function submit() $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($this->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + if ($this->from_onboarding) { // Complete the boarding when server is successfully created via Hetzner currentTeam()->update([ diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 08e9b5bd4..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:01:53+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 17436ccfe..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:01:53+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index 27c00d92b..46311e5b5 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -278,6 +278,27 @@ $response->assertJsonCount(2); $response->assertJsonFragment(['name' => 'web-firewall']); }); + + test('member read token cannot use a stored cloud provider token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake([ + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'firewalls' => [['id' => 38, 'name' => 'web-firewall']], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); + }); }); describe('GET /api/v1/hetzner/networks', function () { @@ -301,6 +322,27 @@ $response->assertJsonCount(2); $response->assertJsonFragment(['name' => 'private-eu']); }); + + test('member read token cannot use a stored cloud provider token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake([ + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'networks' => [['id' => 456, 'name' => 'private-eu']], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); + }); }); describe('POST /api/v1/servers/hetzner', function () { @@ -411,6 +453,67 @@ && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup'); }); + test('registers server when backup enablement fails after Hetzner creation', function () { + Http::fake(function (HttpRequest $request) { + if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) { + return Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') { + return Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') { + return Http::response([ + 'server' => [ + 'id' => 456, + 'name' => 'test-server', + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') { + return Http::response([ + 'error' => ['message' => 'backup unavailable'], + ], 500); + } + + return Http::response([], 404); + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => true, + 'enable_ipv6' => true, + 'enable_backups' => true, + ]); + + $response->assertCreated(); + $response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']); + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'team_id' => $this->team->id, + 'hetzner_server_id' => 456, + ]); + }); + test('generates server name if not provided', function () { Http::fake([ 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ @@ -711,4 +814,38 @@ $response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']); expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); }); + + test('firewalls endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('networks endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); }); diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php index 0e4a228db..e5efe3862 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -144,15 +144,6 @@ ->and(count($sshKeys))->toBe(3); }); -it('validates network array merging removes duplicate Hetzner network ids', function () { - $selectedHetznerNetworkIds = [456, 789, 456]; - - $networkIds = array_values(array_unique($selectedHetznerNetworkIds)); - - expect($networkIds)->toBe([456, 789]) - ->and(count($networkIds))->toBe(2); -}); - describe('Boarding Flow Integration', function () { uses(RefreshDatabase::class); @@ -171,7 +162,7 @@ test('completes boarding when server is created from onboarding', function () { // Verify boarding is initially enabled - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component with from_onboarding flag $component = Livewire::test(ByHetzner::class) @@ -188,23 +179,51 @@ test('boarding flag remains unchanged when not from onboarding', function () { // Verify boarding is initially enabled - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component without from_onboarding flag (default false) Livewire::test(ByHetzner::class) ->set('from_onboarding', false); // Boarding should still be enabled since it wasn't created from onboarding - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); }); - test('shows the backups option with the pricing note in the create dialog', function () { + test('keeps advanced Hetzner options collapsed by default in the create dialog', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) - ->assertSet('enable_backups', false) + ->assertSet('show_advanced_hetzner_options', false) + ->assertSee('Advanced Hetzner options') + ->assertDontSee('Extra SSH Keys') + ->assertDontSee('Firewalls') + ->assertDontSee('Private Networks') + ->assertDontSee('Enable Hetzner Backups') + ->assertDontSee('Cloud-Init Script'); + }); + + test('shows advanced Hetzner options when expanded', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->set('show_advanced_hetzner_options', true) + ->assertSee('Extra SSH Keys') + ->assertSee('Firewalls') + ->assertSee('Private Networks') ->assertSee('Enable Hetzner Backups') + ->assertSee('Add cloud-init script') ->assertSee('additional 20% of the server monthly fee'); }); + + test('shows the cloud init script name only when saving the script', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->set('show_advanced_hetzner_options', true) + ->set('show_cloud_init_script', true) + ->assertSee('Cloud-Init Script') + ->assertSee('Save this script for later use') + ->assertDontSee('Script name...') + ->set('save_cloud_init_script', true) + ->assertSee('Script name...'); + }); }); describe('Hetzner data loading', function () { @@ -239,7 +258,7 @@ ], 200), 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ - ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], + ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), From b939e09f12c717f665e5a933289931aee6c1ca72 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:36:11 +0200 Subject: [PATCH 4/6] refactor(hetzner): move advanced options into dropdown --- app/Livewire/Server/New/ByHetzner.php | 27 --- resources/views/components/dropdown.blade.php | 33 ++- .../livewire/server/new/by-hetzner.blade.php | 193 ++++++++++-------- .../Server/HetznerServerCreationTest.php | 17 +- 4 files changed, 140 insertions(+), 130 deletions(-) diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 0da4ef344..f991bc3e8 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -74,8 +74,6 @@ class ByHetzner extends Component public bool $enable_backups = false; - public bool $show_advanced_hetzner_options = false; - public bool $show_cloud_init_script = false; public ?string $cloud_init_script = null; @@ -131,7 +129,6 @@ public function resetSelection() $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; - $this->show_advanced_hetzner_options = false; $this->show_cloud_init_script = false; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; @@ -191,7 +188,6 @@ protected function rules(): array 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', 'enable_backups' => 'required|boolean', - 'show_advanced_hetzner_options' => 'boolean', 'show_cloud_init_script' => 'boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', @@ -452,22 +448,6 @@ public function getSelectedServerBackupSurchargeProperty(): ?string return '€'.number_format($price * 0.2, 2); } - public function getShouldShowAdvancedHetznerOptionsProperty(): bool - { - return $this->show_advanced_hetzner_options - || $this->selectedHetznerSshKeyIds !== [] - || $this->selectedHetznerFirewallIds !== [] - || $this->selectedHetznerNetworkIds !== [] - || $this->enable_backups - || ! $this->enable_ipv4 - || ! $this->enable_ipv6 - || $this->show_cloud_init_script - || filled($this->cloud_init_script) - || $this->save_cloud_init_script - || filled($this->cloud_init_script_name) - || filled($this->selected_cloud_init_script_id); - } - public function getAdvancedHetznerOptionsSummaryProperty(): array { $summary = []; @@ -502,15 +482,9 @@ public function getAdvancedHetznerOptionsSummaryProperty(): array return $summary; } - public function toggleAdvancedHetznerOptions(): void - { - $this->show_advanced_hetzner_options = ! $this->show_advanced_hetzner_options; - } - public function showCloudInitScript(): void { $this->show_cloud_init_script = true; - $this->show_advanced_hetzner_options = true; } public function updatedSelectedLocation($value) @@ -545,7 +519,6 @@ public function updatedSelectedCloudInitScriptId($value) $this->cloud_init_script = $script->script; $this->cloud_init_script_name = $script->name; $this->show_cloud_init_script = true; - $this->show_advanced_hetzner_options = true; } } diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php index 2bb917f79..b48b04143 100644 --- a/resources/views/components/dropdown.blade.php +++ b/resources/views/components/dropdown.blade.php @@ -1,3 +1,9 @@ +@props([ + 'inline' => false, + 'triggerClass' => '', + 'panelClass' => '', +]) +
- -
-
+ :style="panelStyles" @class([ + 'mt-1 w-full' => $inline, + 'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline, + ]) x-cloak> +
! $inline, + 'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline, + $panelClass, + ])> {{ $slot }}
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 2a25e9958..5587c6e39 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -135,95 +135,118 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-

@endif
-
- - @foreach ($hetznerSshKeys as $sshKey) - - @endforeach - -
- -
- - @foreach ($hetznerFirewalls as $firewall) - - @endforeach - -
- -
- - @foreach ($this->availableNetworks as $network) - - @endforeach - -
- -
- -
- - -
-
- -
- - -
- -
-
- - @if ($saved_cloud_init_scripts->count() > 0) -
- - - @foreach ($saved_cloud_init_scripts as $script) - - @endforeach - - - Clear - -
+ + + Advanced Hetzner options + SSH keys, firewalls, private networks, backups, and cloud-init. + @if (count($this->advancedHetznerOptionsSummary) > 0) + + @foreach ($this->advancedHetznerOptionsSummary as $summaryItem) + + {{ $summaryItem }} + + @endforeach + @endif -
- + -
- -
- +
+
+ + @foreach ($hetznerSshKeys as $sshKey) + + @endforeach + +
+ +
+ + @foreach ($hetznerFirewalls as $firewall) + + @endforeach + + + + @foreach ($this->availableNetworks as $network) + + @endforeach + +
+ +
+ + + +
+ +
+ @if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id)) +
+ + Add cloud-init script + +
+ @else +
+ + @if ($saved_cloud_init_scripts->count() > 0) +
+ + + @foreach ($saved_cloud_init_scripts as $script) + + @endforeach + + + Clear + +
+ @else + + Remove + + @endif +
+ + +
+ + @if ($save_cloud_init_script) +
+ +
+ @endif +
+ @endif
-
+
diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php index e5efe3862..34e25d58a 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -189,22 +189,20 @@ expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); }); - test('keeps advanced Hetzner options collapsed by default in the create dialog', function () { + test('uses the shared dropdown UI for advanced Hetzner options', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) - ->assertSet('show_advanced_hetzner_options', false) ->assertSee('Advanced Hetzner options') - ->assertDontSee('Extra SSH Keys') - ->assertDontSee('Firewalls') - ->assertDontSee('Private Networks') - ->assertDontSee('Enable Hetzner Backups') - ->assertDontSee('Cloud-Init Script'); + ->assertSeeHtml('dropdownOpen') + ->assertSeeHtml('x-ref="panel"') + ->assertSeeHtml('dark:bg-coolgray-100') + ->assertSeeHtml('dark:bg-transparent') + ->assertSeeHtml('@click.outside="if (! true) close()"'); }); - test('shows advanced Hetzner options when expanded', function () { + test('renders advanced Hetzner option controls inside the dropdown menu', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) - ->set('show_advanced_hetzner_options', true) ->assertSee('Extra SSH Keys') ->assertSee('Firewalls') ->assertSee('Private Networks') @@ -216,7 +214,6 @@ test('shows the cloud init script name only when saving the script', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) - ->set('show_advanced_hetzner_options', true) ->set('show_cloud_init_script', true) ->assertSee('Cloud-Init Script') ->assertSee('Save this script for later use') From 0b33eb4e9cddbb36b6c993c62e45f72105016cda Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:37:33 +0200 Subject: [PATCH 5/6] fix(ui): constrain helper popup within viewport --- resources/css/utilities.css | 2 +- resources/views/components/helper.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/css/utilities.css b/resources/css/utilities.css index c982f9f86..1df8ac836 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -289,7 +289,7 @@ @utility info-helper { } @utility info-helper-popup { - @apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words; + @apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words; } @utility buyme { diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 2542839f1..2102e48b1 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,5 +1,5 @@
merge(['class' => 'group']) }}> + {{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}>
@isset($icon) {{ $icon }} From a8000ac2ad2fa0882223b8360028e21e9aaa6ad4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:41:17 +0200 Subject: [PATCH 6/6] fix(hetzner): require at least one public IP protocol --- .../Controllers/Api/HetznerController.php | 10 +++++++++ app/Livewire/Server/New/ByHetzner.php | 7 ++++++ tests/Feature/Api/HetznerApiTest.php | 22 +++++++++++++++++++ .../Server/HetznerServerCreationTest.php | 21 +++++++++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 151f9854b..4cadc0eb6 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -824,6 +824,16 @@ public function createServer(Request $request) $request->offsetSet('instant_validate', false); } + if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'enable_ipv4' => ['Enable at least one public IP protocol.'], + 'enable_ipv6' => ['Enable at least one public IP protocol.'], + ], + ], 422); + } + // Validate cloud provider token $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index f991bc3e8..96740537d 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -620,6 +620,13 @@ public function submit() { $this->validate(); + if (! $this->enable_ipv4 && ! $this->enable_ipv6) { + $this->addError('enable_ipv4', 'Enable at least one public IP protocol.'); + $this->addError('enable_ipv6', 'Enable at least one public IP protocol.'); + + return null; + } + try { $this->authorize('create', Server::class); diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index 46311e5b5..8c1856f02 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -669,6 +669,28 @@ $response->assertJsonFragment(['ip' => '2001:db8::1']); }); + test('rejects server creation when both public IP protocols are disabled', function () { + Http::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => false, + 'enable_ipv6' => false, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['enable_ipv4', 'enable_ipv6']); + Http::assertNothingSent(); + }); + test('passes selected firewalls and networks to Hetzner server creation', function () { Http::fake([ 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php index 34e25d58a..957641601 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -240,7 +240,7 @@ 'token' => 'test-hetzner-api-token', ]); - PrivateKey::factory()->create([ + $this->privateKey = PrivateKey::factory()->create([ 'team_id' => $this->team->id, ]); }); @@ -298,4 +298,23 @@ expect($component->get('hetznerFirewalls'))->toHaveCount(1) ->and($component->get('hetznerNetworks'))->toHaveCount(1); }); + + test('rejects submitting without a public IP protocol before calling Hetzner', function () { + Http::fake(); + + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->set('selected_token_id', $this->hetznerToken->id) + ->set('server_name', 'test-server') + ->set('selected_location', 'nbg1') + ->set('selected_server_type', 'cx11') + ->set('selected_image', 15512617) + ->set('private_key_id', $this->privateKey->id) + ->set('enable_ipv4', false) + ->set('enable_ipv6', false) + ->call('submit') + ->assertHasErrors(['enable_ipv4', 'enable_ipv6']); + + Http::assertNothingSent(); + }); });