diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 1c9d6f9ef..4cadc0eb6 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -460,6 +460,195 @@ 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); + } + $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 Hetzner firewalls.'], 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); + } + $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 Hetzner networks.'], 500); + } + } + #[OA\Post( summary: 'Create Hetzner Server', description: 'Create a new server on Hetzner and register it in Coolify.', @@ -487,7 +676,10 @@ public function sshKeys(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'], 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], ], @@ -545,7 +737,10 @@ public function createServer(Request $request) 'private_key_uuid', 'enable_ipv4', 'enable_ipv6', + 'enable_backups', 'hetzner_ssh_key_ids', + 'hetzner_firewall_ids', + 'hetzner_network_ids', 'cloud_init_script', 'instant_validate', ]; @@ -571,8 +766,13 @@ 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', + 'hetzner_firewall_ids.*' => 'integer', + 'hetzner_network_ids' => 'nullable|array', + 'hetzner_network_ids.*' => 'integer', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'instant_validate' => 'nullable|boolean', ]); @@ -608,13 +808,32 @@ 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', []); } + 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); } + 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) @@ -687,6 +906,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; @@ -723,6 +954,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 9ae065d83..96740537d 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; @@ -64,6 +72,10 @@ class ByHetzner extends Component public bool $enable_ipv6 = true; + public bool $enable_backups = false; + + public bool $show_cloud_init_script = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -112,10 +124,15 @@ 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; $this->selected_cloud_init_script_id = null; + $this->show_cloud_init_script = false; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; } public function loadTokens() @@ -164,8 +181,14 @@ 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', + 'enable_backups' => 'required|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', @@ -245,6 +268,9 @@ public function previousStep() private function loadHetznerData(string $token) { $this->loading_data = true; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; try { $hetznerService = new HetznerService($token); @@ -274,6 +300,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; @@ -349,6 +383,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) { @@ -366,11 +431,74 @@ 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 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 showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + } + 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) @@ -390,6 +518,14 @@ 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; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; } } @@ -399,12 +535,11 @@ 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(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); @@ -458,6 +593,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; @@ -473,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); @@ -492,9 +646,10 @@ public function submit() } $hetznerToken = $this->getHetznerToken(); + $hetznerService = new HetznerService($hetznerToken); // Create server on Hetzner - $hetznerServer = $this->createHetznerServer($hetznerToken); + $hetznerServer = $this->createHetznerServer($hetznerService); // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; @@ -524,6 +679,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/app/Services/HetznerService.php b/app/Services/HetznerService.php index 099aecf2e..27dbc4a83 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -118,6 +118,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', [ @@ -136,6 +146,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/openapi.json b/openapi.json index 3b324a546..cccf5a139 100644 --- a/openapi.json +++ b/openapi.json @@ -8784,6 +8784,139 @@ ] } }, + "\/hetzner\/firewalls": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Firewalls", + "description": "Get all existing Hetzner firewalls for the current project.", + "operationId": "get-hetzner-firewalls", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner firewalls.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/networks": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Networks", + "description": "Get all existing Hetzner private networks for the current project.", + "operationId": "get-hetzner-networks", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner networks.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "ip_range": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/servers\/hetzner": { "post": { "tags": [ @@ -8851,6 +8984,11 @@ "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": { @@ -8858,6 +8996,20 @@ }, "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)" diff --git a/openapi.yaml b/openapi.yaml index 03486bb40..619eed53c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5606,6 +5606,86 @@ paths: security: - bearerAuth: [] + /hetzner/firewalls: + get: + tags: + - Hetzner + summary: 'Get Hetzner Firewalls' + description: 'Get all existing Hetzner firewalls for the current project.' + operationId: get-hetzner-firewalls + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner firewalls.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/networks: + get: + tags: + - Hetzner + summary: 'Get Hetzner Networks' + description: 'Get all existing Hetzner private networks for the current project.' + operationId: get-hetzner-networks + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner networks.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, ip_range: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /servers/hetzner: post: tags: @@ -5662,10 +5742,22 @@ paths: 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' cloud_init_script: type: string description: 'Cloud-init YAML script (optional)' 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/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/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 }} diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index cb2cee06f..5587c6e39 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'] }}] @@ -135,58 +135,118 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-

@endif
-
- - @foreach ($hetznerSshKeys as $sshKey) - - @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
-
+
@@ -201,4 +261,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 5da528d0a..2564b9389 100644 --- a/routes/api.php +++ b/routes/api.php @@ -105,6 +105,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/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index 728b99d0e..8c1856f02 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -6,14 +6,22 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request as HttpRequest; use Illuminate\Support\Facades\Http; use Illuminate\Support\Once; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('cache.default', 'array'); + config()->set('app.maintenance.driver', 'file'); + config()->set('app.maintenance.store', 'array'); + InstanceSettings::query()->whereKey(0)->delete(); - $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings = new InstanceSettings([ + 'is_api_enabled' => true, + 'is_registration_enabled' => true, + ]); $settings->id = 0; $settings->save(); Once::flush(); @@ -29,15 +37,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-----', ]); }); @@ -239,17 +257,105 @@ }); }); +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']); + }); + + 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 () { + 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']); + }); + + 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 () { test('creates a Hetzner server', function () { // Mock Hetzner API calls Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201), '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, 'fingerprint' => 'aa:bb:cc:dd'], - ], 201), 'https://api.hetzner.cloud/v1/servers' => Http::response([ 'server' => [ 'id' => 456, @@ -289,15 +395,134 @@ ]); }); + 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('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([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201), '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, 'fingerprint' => 'aa:bb:cc:dd'], - ], 201), 'https://api.hetzner.cloud/v1/servers' => Http::response([ 'server' => [ 'id' => 456, @@ -331,13 +556,10 @@ 'Content-Type' => 'application/json', ])->postJson('/api/v1/servers/hetzner', []); - $response->assertStatus(422); - $response->assertJsonValidationErrors([ - 'cloud_provider_token_id', - 'location', - 'server_type', - 'image', - 'private_key_uuid', + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Invalid request.', + 'error' => 'Invalid JSON.', ]); }); @@ -375,13 +597,13 @@ test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () { Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), '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, @@ -412,13 +634,13 @@ test('uses IPv6 when only IPv6 is enabled', function () { Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), '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, @@ -447,6 +669,75 @@ $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([ + 'ssh_key' => ['id' => 123], + ], 201), + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + '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, @@ -545,4 +836,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 8f1a13d7a..957641601 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -1,9 +1,12 @@ 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) @@ -176,13 +179,142 @@ 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('uses the shared dropdown UI for advanced Hetzner options', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->assertSee('Advanced Hetzner options') + ->assertSeeHtml('dropdownOpen') + ->assertSeeHtml('x-ref="panel"') + ->assertSeeHtml('dark:bg-coolgray-100') + ->assertSeeHtml('dark:bg-transparent') + ->assertSeeHtml('@click.outside="if (! true) close()"'); + }); + + test('renders advanced Hetzner option controls inside the dropdown menu', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->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_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 () { + 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', + ]); + + $this->privateKey = 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', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, '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); + }); + + 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(); }); });