cloud_provider_token_uuid ?? $request->cloud_provider_token_id; } #[OA\Get( summary: 'Get Hetzner Locations', description: 'Get all available Hetzner datacenter locations.', path: '/hetzner/locations', operationId: 'get-hetzner-locations', 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 locations.', 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'], 'description' => ['type' => 'string'], 'country' => ['type' => 'string'], 'city' => ['type' => 'string'], 'latitude' => ['type' => 'number'], 'longitude' => ['type' => 'number'], ] ) ) ), ]), new OA\Response( response: 401, ref: '#/components/responses/401', ), new OA\Response( response: 404, ref: '#/components/responses/404', ), new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function locations(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); $locations = $hetznerService->getLocations(); return response()->json($locations); } catch (CloudProviderApiException $e) { return $e->toJsonResponse('Failed to fetch Hetzner locations.'); } catch (\Throwable $e) { report($e); return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500); } } #[OA\Get( summary: 'Get Hetzner Server Types', description: 'Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.', path: '/hetzner/server-types', operationId: 'get-hetzner-server-types', security: [ ['bearerAuth' => []], ], tags: ['Hetzner'], parameters: [ new OA\Parameter( name: 'location', in: 'query', required: false, description: 'Return only the server types that can be ordered in this location, for example nbg1.', schema: new OA\Schema(type: 'string') ), 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 server types.', 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'], 'description' => ['type' => 'string'], 'cores' => ['type' => 'integer'], 'memory' => ['type' => 'number'], 'disk' => ['type' => 'integer'], 'prices' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'location' => ['type' => 'string', 'description' => 'Datacenter location name'], 'price_hourly' => [ 'type' => 'object', 'properties' => [ 'net' => ['type' => 'string'], 'gross' => ['type' => 'string'], ], ], 'price_monthly' => [ 'type' => 'object', 'properties' => [ 'net' => ['type' => 'string'], 'gross' => ['type' => 'string'], ], ], ], ], ], ] ) ) ), ]), new OA\Response( response: 401, ref: '#/components/responses/401', ), new OA\Response( response: 404, ref: '#/components/responses/404', ), new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function serverTypes(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', 'location' => 'nullable|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); $serverTypes = $hetznerService->getServerTypes($request->query('location')); return response()->json($serverTypes); } catch (CloudProviderApiException $e) { return $e->toJsonResponse('Failed to fetch Hetzner server types.'); } catch (\Throwable $e) { report($e); return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500); } } #[OA\Get( summary: 'Get Hetzner Images', description: 'Get all available Hetzner system images (operating systems).', path: '/hetzner/images', operationId: 'get-hetzner-images', 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 images.', 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'], 'description' => ['type' => 'string'], 'type' => ['type' => 'string'], 'os_flavor' => ['type' => 'string'], 'os_version' => ['type' => 'string'], 'architecture' => ['type' => 'string'], ] ) ) ), ]), new OA\Response( response: 401, ref: '#/components/responses/401', ), new OA\Response( response: 404, ref: '#/components/responses/404', ), new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function images(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); $images = $hetznerService->getImages(); // Filter out deprecated images (same as UI) $filtered = array_filter($images, function ($image) { if (isset($image['type']) && $image['type'] !== 'system') { return false; } if (isset($image['deprecated']) && $image['deprecated'] === true) { return false; } return true; }); return response()->json(array_values($filtered)); } catch (CloudProviderApiException $e) { return $e->toJsonResponse('Failed to fetch Hetzner images.'); } catch (\Throwable $e) { report($e); return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500); } } #[OA\Get( summary: 'Get Hetzner SSH Keys', description: 'Get all SSH keys stored in the Hetzner account.', path: '/hetzner/ssh-keys', operationId: 'get-hetzner-ssh-keys', 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 SSH keys.', 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'], 'fingerprint' => ['type' => 'string'], 'public_key' => ['type' => 'string'], ] ) ) ), ]), new OA\Response( response: 401, ref: '#/components/responses/401', ), new OA\Response( response: 404, ref: '#/components/responses/404', ), new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function sshKeys(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); $sshKeys = $hetznerService->getSshKeys(); return response()->json($sshKeys); } catch (CloudProviderApiException $e) { return $e->toJsonResponse('Failed to fetch Hetzner SSH keys.'); } catch (\Throwable $e) { report($e); return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500); } } #[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', ), new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] 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 (CloudProviderApiException $e) { return $e->toJsonResponse('Failed to fetch Hetzner firewalls.'); } catch (\Throwable $e) { report($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', ), new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] 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 (CloudProviderApiException $e) { return $e->toJsonResponse('Failed to fetch Hetzner networks.'); } catch (\Throwable $e) { report($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. A cloud-init script or extra SSH key IDs need a token with the deploy permission.', path: '/servers/hetzner', operationId: 'create-hetzner-server', security: [ ['bearerAuth' => []], ], tags: ['Hetzner'], requestBody: new OA\RequestBody( required: true, description: 'Hetzner server creation parameters', content: new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( type: 'object', required: ['location', 'server_type', 'image', 'private_key_uuid'], properties: [ 'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'], 'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true], 'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'], 'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'], 'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'], 'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'], '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. Needs a token with the deploy permission.'], '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). Needs a token with the deploy permission.'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate the server and install Docker when it is running. Defaults to true for tokens with the deploy permission, and needs that permission.'], ], ), ), ), responses: [ new OA\Response( response: 201, description: 'Hetzner server created.', content: [ new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( type: 'object', properties: [ 'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'], 'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'], 'ip' => ['type' => 'string', 'description' => 'The server IP address.'], ] ) ), ]), new OA\Response( response: 401, ref: '#/components/responses/401', ), new OA\Response( response: 400, ref: '#/components/responses/400', ), new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'), new OA\Response( response: 404, ref: '#/components/responses/404', ), new OA\Response( response: 422, ref: '#/components/responses/422', ), new OA\Response( response: 429, ref: '#/components/responses/429', ), new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function createServer(Request $request) { $allowedFields = [ 'cloud_provider_token_uuid', 'cloud_provider_token_id', 'location', 'server_type', 'image', 'name', 'private_key_uuid', 'enable_ipv4', 'enable_ipv6', 'enable_backups', 'hetzner_ssh_key_ids', 'hetzner_firewall_ids', 'hetzner_network_ids', 'cloud_init_script', 'instant_validate', ]; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } $this->authorize('create', [Server::class]); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { return $return; } $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', 'location' => 'required|string', 'server_type' => 'required|string', 'image' => 'required|integer', 'name' => ['nullable', 'string', 'max:253', new ValidHostname], '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', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); if (! empty($extraFields)) { foreach ($extraFields as $field) { $errors->add($field, 'This field is not allowed.'); } } return response()->json([ 'message' => 'Validation failed.', 'errors' => $errors, ], 422); } if (! empty($request->cloud_init_script) || ! empty($request->hetzner_ssh_key_ids)) { $forbidden = $this->deployAbilityForbiddenResponse($request, 'A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.'); if ($forbidden) { return $forbidden; } } // Check server limit $team = Team::find($teamId); if (Team::serverLimitReached($team)) { return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } // Set defaults if (! $request->name) { $request->offsetSet('name', generate_random_name()); } if (is_null($request->enable_ipv4)) { $request->offsetSet('enable_ipv4', true); } 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', []); } $request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request)); if ($request->instant_validate) { $forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.'); if ($forbidden) { return $forbidden; } } 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) ->whereUuid($tokenUuid) ->where('provider', 'hetzner') ->first(); if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } $this->authorize('view', $token); // Validate private key $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } try { $hetznerService = new HetznerService($token->token); // Get public key and MD5 fingerprint $publicKey = $privateKey->getPublicKey(); $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); // Check if SSH key already exists on Hetzner $existingSshKeys = $hetznerService->getSshKeys(); $existingKey = null; foreach ($existingSshKeys as $key) { if ($key['fingerprint'] === $md5Fingerprint) { $existingKey = $key; break; } } // Upload SSH key if it doesn't exist if ($existingKey) { $sshKeyId = $existingKey['id']; } else { $sshKeyName = $privateKey->name; $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); $sshKeyId = $uploadedKey['id']; } // Normalize server name to lowercase for RFC 1123 compliance $normalizedServerName = strtolower(trim($request->name)); // Prepare SSH keys array: Coolify key + user-selected Hetzner keys $sshKeys = array_merge( [$sshKeyId], $request->hetzner_ssh_key_ids ); // Remove duplicates $sshKeys = array_unique($sshKeys); $sshKeys = array_values($sshKeys); // Prepare server creation parameters $params = [ 'name' => $normalizedServerName, 'server_type' => $request->server_type, 'image' => $request->image, 'location' => $request->location, 'start_after_create' => true, 'ssh_keys' => $sshKeys, 'public_net' => [ 'enable_ipv4' => $request->enable_ipv4, 'enable_ipv6' => $request->enable_ipv6, ], ]; $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; } // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { $ipAddress = hetznerServerIpv6($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 = Team::createServerWithinLimit($teamId, [ 'name' => $normalizedServerName, 'ip' => $ipAddress, 'user' => 'root', 'port' => 22, 'team_id' => $teamId, 'private_key_id' => $privateKey->id, 'cloud_provider_token_id' => $token->id, 'hetzner_server_id' => $hetznerServer['id'], 'hetzner_server_status' => $hetznerServer['status'] ?? null, ]); $server->proxy->set('status', 'exited'); $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) { ValidateNewCloudServerJob::dispatch($server); } auditLog('api.hetzner_server.created', [ 'team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'hetzner_server_id' => $hetznerServer['id'], 'ip' => $ipAddress, ]); return response()->json([ 'uuid' => $server->uuid, 'hetzner_server_id' => $hetznerServer['id'], 'ip' => $ipAddress, ])->setStatusCode(201); } catch (ValidationException $e) { if (! isset($e->errors()['server'])) { throw $e; } if (isset($hetznerService, $hetznerServer['id'])) { try { $hetznerService->deleteServer((int) $hetznerServer['id']); } catch (\Throwable $cleanupError) { report($cleanupError); } } return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } catch (RateLimitException $e) { $response = response()->json(['message' => $e->getMessage()], 429); if ($e->retryAfter !== null) { $response->header('Retry-After', $e->retryAfter); } return $response; } catch (CloudProviderApiException $e) { return $e->toJsonResponse('Failed to create Hetzner server.'); } catch (\Throwable $e) { report($e); if (isset($hetznerService, $hetznerServer['id'])) { try { $hetznerService->deleteServer((int) $hetznerServer['id']); } catch (\Throwable $cleanupError) { report($cleanupError); } } return response()->json(['message' => 'Failed to create Hetzner server.'], 500); } } }