authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } } public function loadSavedCloudInitScripts() { $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); } public function getListeners() { return [ 'tokenAdded' => 'handleTokenAdded', 'privateKeyCreated' => 'handlePrivateKeyCreated', 'modalClosed' => 'resetSelection', ]; } public function resetSelection() { $this->selected_token_id = null; $this->current_step = 1; $this->cloud_init_script = null; $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; } public function loadTokens() { $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() ->where('provider', 'hetzner') ->get(); } public function handleTokenAdded($tokenId) { // Refresh token list $this->loadTokens(); // Auto-select the new token $this->selected_token_id = $tokenId; // Automatically proceed to next step $this->nextStep(); } public function handlePrivateKeyCreated($keyId) { // Refresh private keys list $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); // Auto-select the new key $this->private_key_id = $keyId; // Clear validation errors for private_key_id $this->resetErrorBag('private_key_id'); } protected function rules(): array { $rules = [ 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', ]; if ($this->current_step === 2) { $rules = array_merge($rules, [ 'server_name' => ['required', 'string', 'max:253', new ValidHostname], 'selected_location' => 'required|string', 'selected_image' => 'required|integer', 'selected_server_type' => 'required|string', 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, 'selectedHetznerSshKeyIds' => 'nullable|array', 'selectedHetznerSshKeyIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', ]); } return $rules; } protected function messages(): array { return [ 'selected_token_id.required' => 'Please select a Hetzner token.', 'selected_token_id.exists' => 'Selected token not found.', ]; } public function selectToken(int $tokenId) { $this->selected_token_id = $tokenId; } private function validateHetznerToken(string $token): bool { try { $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); return $response->successful(); } catch (\Throwable $e) { return false; } } private function getHetznerToken(): string { if ($this->selected_token_id) { $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); return $token ? $token->token : ''; } return ''; } public function nextStep() { // Validate step 1 - just need a token selected $this->validate([ 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', ]); try { $hetznerToken = $this->getHetznerToken(); if (! $hetznerToken) { return $this->dispatch('error', 'Please select a valid Hetzner token.'); } // Load Hetzner data $this->loadHetznerData($hetznerToken); // Move to step 2 $this->current_step = 2; } catch (\Throwable $e) { return handleError($e, $this); } } public function previousStep() { $this->current_step = 1; } private function loadHetznerData(string $token) { $this->loading_data = true; try { $hetznerService = new HetznerService($token); $this->locations = $hetznerService->getLocations(); $this->serverTypes = $hetznerService->getServerTypes(); // Get images and sort by name $images = $hetznerService->getImages(); $this->images = collect($images) ->filter(function ($image) { // Only system images if (! isset($image['type']) || $image['type'] !== 'system') { return false; } // Filter out deprecated images if (isset($image['deprecated']) && $image['deprecated'] === true) { return false; } return true; }) ->sortBy('name') ->values() ->toArray(); // Load SSH keys from Hetzner $this->hetznerSshKeys = $hetznerService->getSshKeys(); $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; throw $e; } } private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); if (str_starts_with($name, 'ccx')) { return 'AMD Milan EPYC™'; } elseif (str_starts_with($name, 'cpx')) { return 'AMD EPYC™'; } elseif (str_starts_with($name, 'cx')) { return 'Intel®/AMD'; } elseif (str_starts_with($name, 'cax')) { return 'Ampere®'; } return null; } public function getAvailableServerTypesProperty() { ray('Getting available server types', [ 'selected_location' => $this->selected_location, 'total_server_types' => count($this->serverTypes), ]); if (! $this->selected_location) { return $this->serverTypes; } $filtered = collect($this->serverTypes) ->filter(function ($type) { if (! isset($type['locations'])) { return false; } $locationNames = collect($type['locations'])->pluck('name')->toArray(); return in_array($this->selected_location, $locationNames); }) ->map(function ($serverType) { $serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType); return $serverType; }) ->values() ->toArray(); ray('Filtered server types', [ 'selected_location' => $this->selected_location, 'filtered_count' => count($filtered), ]); return $filtered; } public function getAvailableImagesProperty() { ray('Getting available images', [ 'selected_server_type' => $this->selected_server_type, 'total_images' => count($this->images), 'images' => $this->images, ]); if (! $this->selected_server_type) { return $this->images; } $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); ray('Server type data', $serverType); if (! $serverType || ! isset($serverType['architecture'])) { ray('No architecture in server type, returning all'); return $this->images; } $architecture = $serverType['architecture']; $filtered = collect($this->images) ->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture) ->values() ->toArray(); ray('Filtered images', [ 'architecture' => $architecture, 'filtered_count' => count($filtered), ]); return $filtered; } public function getSelectedServerPriceProperty(): ?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 = $serverType['prices'][0]['price_monthly']['gross']; return '€'.number_format($price, 2); } public function updatedSelectedLocation($value) { ray('Location selected', $value); // Reset server type and image when location changes $this->selected_server_type = null; $this->selected_image = null; } public function updatedSelectedServerType($value) { ray('Server type selected', $value); // Reset image when server type changes $this->selected_image = null; } public function updatedSelectedImage($value) { ray('Image selected', $value); } public function updatedSelectedCloudInitScriptId($value) { if ($value) { $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); $this->cloud_init_script = $script->script; $this->cloud_init_script_name = $script->name; } } public function clearCloudInitScript() { $this->selected_cloud_init_script_id = null; $this->cloud_init_script = ''; $this->cloud_init_script_name = ''; $this->save_cloud_init_script = false; } private function createHetznerServer(string $token): array { $hetznerService = new HetznerService($token); // Get the private key and extract public key $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $publicKey = $privateKey->getPublicKey(); $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); ray('Private Key Info', [ 'private_key_id' => $this->private_key_id, 'sha256_fingerprint' => $privateKey->fingerprint, 'md5_fingerprint' => $md5Fingerprint, ]); // Check if SSH key already exists on Hetzner by comparing MD5 fingerprints $existingSshKeys = $hetznerService->getSshKeys(); $existingKey = null; ray('Existing SSH Keys on Hetzner', $existingSshKeys); foreach ($existingSshKeys as $key) { if ($key['fingerprint'] === $md5Fingerprint) { $existingKey = $key; break; } } // Upload SSH key if it doesn't exist if ($existingKey) { $sshKeyId = $existingKey['id']; ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]); } else { $sshKeyName = $privateKey->name; $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); $sshKeyId = $uploadedKey['id']; ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]); } // Normalize server name to lowercase for RFC 1123 compliance $normalizedServerName = strtolower(trim($this->server_name)); // Prepare SSH keys array: Coolify key + user-selected Hetzner keys $sshKeys = array_merge( [$sshKeyId], // Coolify key (always included) $this->selectedHetznerSshKeyIds // User-selected Hetzner keys ); // Remove duplicates in case the Coolify key was also selected $sshKeys = array_unique($sshKeys); $sshKeys = array_values($sshKeys); // Re-index array // Prepare server creation parameters $params = [ 'name' => $normalizedServerName, 'server_type' => $this->selected_server_type, 'image' => $this->selected_image, 'location' => $this->selected_location, 'start_after_create' => true, 'ssh_keys' => $sshKeys, 'public_net' => [ 'enable_ipv4' => $this->enable_ipv4, 'enable_ipv6' => $this->enable_ipv6, ], ]; // Add cloud-init script if provided if (! empty($this->cloud_init_script)) { $params['user_data'] = $this->cloud_init_script; } ray('Server creation parameters', $params); // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); ray('Hetzner server created', $hetznerServer); return $hetznerServer; } public function submit() { $this->validate(); try { $this->authorize('create', Server::class); if (Team::serverLimitReached()) { return $this->dispatch('error', 'You have reached the server limit for your subscription.'); } // Save cloud-init script if requested if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { $this->authorize('create', CloudInitScript::class); CloudInitScript::create([ 'team_id' => currentTeam()->id, 'name' => $this->cloud_init_script_name, 'script' => $this->cloud_init_script, ]); } $hetznerToken = $this->getHetznerToken(); // Create server on Hetzner $hetznerServer = $this->createHetznerServer($hetznerToken); // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; } if (! $ipAddress) { throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); } // Create server in Coolify database $server = Server::create([ 'name' => $this->server_name, 'ip' => $ipAddress, 'user' => 'root', 'port' => 22, 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, 'cloud_provider_token_id' => $this->selected_token_id, 'hetzner_server_id' => $hetznerServer['id'], ]); $server->proxy->set('status', 'exited'); $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); if ($this->from_onboarding) { // Complete the boarding when server is successfully created via Hetzner currentTeam()->update([ 'show_boarding' => false, ]); refreshSession(); return redirectRoute($this, 'server.show', [$server->uuid]); } return redirectRoute($this, 'server.show', [$server->uuid]); } catch (\Throwable $e) { return handleError($e, $this); } } public function render() { return view('livewire.server.new.by-hetzner'); } }