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); + }); +});