diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php
index 15c892e75..db197a019 100644
--- a/app/Actions/Server/DeleteServer.php
+++ b/app/Actions/Server/DeleteServer.php
@@ -2,16 +2,63 @@
namespace App\Actions\Server;
+use App\Models\CloudProviderToken;
use App\Models\Server;
+use App\Services\HetznerService;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteServer
{
use AsAction;
- public function handle(Server $server)
+ public function handle(Server $server, bool $deleteFromHetzner = false)
{
+ // Delete from Hetzner Cloud if requested and server has hetzner_server_id
+ if ($deleteFromHetzner && $server->hetzner_server_id) {
+ $this->deleteFromHetzner($server);
+ }
+
StopSentinel::run($server);
$server->forceDelete();
}
+
+ private function deleteFromHetzner(Server $server): void
+ {
+ try {
+ // Get the cloud provider token for Hetzner
+ $token = CloudProviderToken::where('team_id', $server->team_id)
+ ->where('provider', 'hetzner')
+ ->first();
+
+ if (! $token) {
+ ray('No Hetzner token found for team, skipping Hetzner deletion', [
+ 'team_id' => $server->team_id,
+ 'server_id' => $server->id,
+ ]);
+
+ return;
+ }
+
+ $hetznerService = new HetznerService($token->token);
+ $hetznerService->deleteServer($server->hetzner_server_id);
+
+ ray('Deleted server from Hetzner', [
+ 'hetzner_server_id' => $server->hetzner_server_id,
+ 'server_id' => $server->id,
+ ]);
+ } catch (\Throwable $e) {
+ ray('Failed to delete server from Hetzner', [
+ 'error' => $e->getMessage(),
+ 'hetzner_server_id' => $server->hetzner_server_id,
+ 'server_id' => $server->id,
+ ]);
+
+ // Log the error but don't prevent the server from being deleted from Coolify
+ logger()->error('Failed to delete server from Hetzner', [
+ 'error' => $e->getMessage(),
+ 'hetzner_server_id' => $server->hetzner_server_id,
+ 'server_id' => $server->id,
+ ]);
+ }
+ }
}
diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php
index f35a3a806..f5726e424 100644
--- a/app/Livewire/Security/CloudProviderTokens.php
+++ b/app/Livewire/Security/CloudProviderTokens.php
@@ -100,7 +100,7 @@ public function addNewToken()
public function deleteToken(int $tokenId)
{
try {
- $token = CloudProviderToken::findOrFail($tokenId);
+ $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
$token->delete();
diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php
index b9e3944b5..6d12895eb 100644
--- a/app/Livewire/Server/Delete.php
+++ b/app/Livewire/Server/Delete.php
@@ -16,6 +16,8 @@ class Delete extends Component
public Server $server;
+ public bool $delete_from_hetzner = false;
+
public function mount(string $server_uuid)
{
try {
@@ -41,8 +43,9 @@ public function delete($password)
return;
}
+
$this->server->delete();
- DeleteServer::dispatch($this->server);
+ DeleteServer::dispatch($this->server, $this->delete_from_hetzner);
return redirect()->route('server.index');
} catch (\Throwable $e) {
@@ -52,6 +55,18 @@ public function delete($password)
public function render()
{
- return view('livewire.server.delete');
+ $checkboxes = [];
+
+ if ($this->server->hetzner_server_id) {
+ $checkboxes[] = [
+ 'id' => 'delete_from_hetzner',
+ 'label' => 'Also delete server from Hetzner Cloud',
+ 'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.',
+ ];
+ }
+
+ return view('livewire.server.delete', [
+ 'checkboxes' => $checkboxes,
+ ]);
}
}
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index d509adcb6..b67411e17 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -2,9 +2,12 @@
namespace App\Livewire\Server\New;
+use App\Enums\ProxyTypes;
use App\Models\CloudProviderToken;
+use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
+use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
@@ -15,6 +18,10 @@ class ByHetzner extends Component
{
use AuthorizesRequests;
+ // Step tracking
+ public int $current_step = 1;
+
+ // Locked data
#[Locked]
public Collection $available_tokens;
@@ -24,6 +31,7 @@ class ByHetzner extends Component
#[Locked]
public $limit_reached;
+ // Step 1: Token selection
public ?int $selected_token_id = null;
public string $hetzner_token = '';
@@ -32,22 +40,69 @@ class ByHetzner extends Component
public ?string $token_name = null;
+ // Step 2: Server configuration
+ public array $locations = [];
+
+ public array $images = [];
+
+ public array $serverTypes = [];
+
+ public ?string $selected_location = null;
+
+ public ?int $selected_image = null;
+
+ public ?string $selected_server_type = null;
+
+ public string $server_name = '';
+
+ public bool $start_after_create = true;
+
+ public ?int $private_key_id = null;
+
+ public bool $loading_data = false;
+
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
+ $this->server_name = generate_random_name();
+ if ($this->private_keys->count() > 0) {
+ $this->private_key_id = $this->private_keys->first()->id;
+ }
}
protected function rules(): array
{
- return [
+ $rules = [
'selected_token_id' => 'nullable|integer',
'hetzner_token' => 'required_without:selected_token_id|string',
'save_token' => 'boolean',
- 'token_name' => 'required_if:save_token,true|nullable|string|max:255',
+ 'token_name' => [
+ 'nullable',
+ 'string',
+ 'max:255',
+ function ($attribute, $value, $fail) {
+ if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) {
+ $fail('Please provide a name for the token.');
+ }
+ },
+ ],
];
+
+ if ($this->current_step === 2) {
+ $rules = array_merge($rules, [
+ 'server_name' => 'required|string|max:255',
+ '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,
+ 'start_after_create' => 'boolean',
+ ]);
+ }
+
+ return $rules;
}
protected function messages(): array
@@ -76,6 +131,275 @@ private function validateHetznerToken(string $token): bool
}
}
+ private function getHetznerToken(): string
+ {
+ if ($this->selected_token_id) {
+ $token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
+
+ return $token ? $token->token : '';
+ }
+
+ return $this->hetzner_token;
+ }
+
+ public function nextStep()
+ {
+ // Validate step 1
+ $this->validate([
+ 'selected_token_id' => 'nullable|integer',
+ 'hetzner_token' => 'required_without:selected_token_id|string',
+ 'save_token' => 'boolean',
+ 'token_name' => [
+ 'nullable',
+ 'string',
+ 'max:255',
+ function ($attribute, $value, $fail) {
+ if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) {
+ $fail('Please provide a name for the token.');
+ }
+ },
+ ],
+ ]);
+
+ try {
+ $hetznerToken = $this->getHetznerToken();
+
+ if (! $hetznerToken) {
+ return $this->dispatch('error', 'Please provide a valid Hetzner API token.');
+ }
+
+ // Validate token if it's a new one
+ if (! $this->selected_token_id) {
+ if (! $this->validateHetznerToken($hetznerToken)) {
+ return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.');
+ }
+
+ // Save token if requested
+ if ($this->save_token) {
+ CloudProviderToken::create([
+ 'team_id' => currentTeam()->id,
+ 'provider' => 'hetzner',
+ 'token' => $this->hetzner_token,
+ 'name' => $this->token_name,
+ ]);
+ }
+ }
+
+ // 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();
+
+ ray('Raw images from Hetzner API', [
+ 'total_count' => count($images),
+ 'types' => collect($images)->pluck('type')->unique()->values(),
+ 'sample' => array_slice($images, 0, 3),
+ ]);
+
+ $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();
+
+ ray('Filtered images', [
+ 'filtered_count' => count($this->images),
+ 'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
+ ]);
+
+ $this->loading_data = false;
+ } catch (\Throwable $e) {
+ $this->loading_data = false;
+ throw $e;
+ }
+ }
+
+ 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);
+ })
+ ->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 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);
+ }
+
+ 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]);
+ }
+
+ // Prepare server creation parameters
+ $params = [
+ 'name' => $this->server_name,
+ 'server_type' => $this->selected_server_type,
+ 'image' => $this->selected_image,
+ 'location' => $this->selected_location,
+ 'start_after_create' => $this->start_after_create,
+ 'ssh_keys' => [$sshKeyId],
+ ];
+
+ 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();
@@ -87,35 +411,27 @@ public function submit()
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
- // Determine which token to use
- if ($this->selected_token_id) {
- $token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
- if (! $token) {
- return $this->dispatch('error', 'Selected token not found.');
- }
- $hetznerToken = $token->token;
- } else {
- $hetznerToken = $this->hetzner_token;
+ $hetznerToken = $this->getHetznerToken();
- // Validate the new token before saving
- if (! $this->validateHetznerToken($hetznerToken)) {
- return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.');
- }
+ // Create server on Hetzner
+ $hetznerServer = $this->createHetznerServer($hetznerToken);
- // If saving the new token
- if ($this->save_token) {
- CloudProviderToken::create([
- 'team_id' => currentTeam()->id,
- 'provider' => 'hetzner',
- 'token' => $this->hetzner_token,
- 'name' => $this->token_name,
- ]);
- }
- }
+ // Create server in Coolify database
+ $server = Server::create([
+ 'name' => $this->server_name,
+ 'ip' => $hetznerServer['public_net']['ipv4']['ip'],
+ 'user' => 'root',
+ 'port' => 22,
+ 'team_id' => currentTeam()->id,
+ 'private_key_id' => $this->private_key_id,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ ]);
- // TODO: Actual Hetzner server provisioning will be implemented in future phase
- // The $hetznerToken variable contains the token to use
- return $this->dispatch('success', 'Hetzner token validated successfully! Server provisioning coming soon.');
+ $server->proxy->set('status', 'exited');
+ $server->proxy->set('type', ProxyTypes::TRAEFIK->value);
+ $server->save();
+
+ return redirect()->route('server.show', $server->uuid);
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index c210f3c5b..08f3f1ebd 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -289,6 +289,17 @@ public static function generateFingerprint($privateKey)
}
}
+ public static function generateMd5Fingerprint($privateKey)
+ {
+ try {
+ $key = PublicKeyLoader::load($privateKey);
+
+ return $key->getPublicKey()->getFingerprint('md5');
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
public static function fingerprintExists($fingerprint, $excludeId = null)
{
$query = self::query()
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 829a4b5aa..e30b10043 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -162,6 +162,7 @@ protected static function booted()
'description',
'private_key_id',
'team_id',
+ 'hetzner_server_id',
];
protected $guarded = [];
diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php
new file mode 100644
index 000000000..039eb81a9
--- /dev/null
+++ b/app/Services/HetznerService.php
@@ -0,0 +1,96 @@
+token = $token;
+ }
+
+ private function request(string $method, string $endpoint, array $data = [])
+ {
+ $response = Http::withHeaders([
+ 'Authorization' => 'Bearer '.$this->token,
+ ])->timeout(30)->{$method}($this->baseUrl.$endpoint, $data);
+
+ if (! $response->successful()) {
+ throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error'));
+ }
+
+ return $response->json();
+ }
+
+ private function requestPaginated(string $method, string $endpoint, string $resourceKey, array $data = []): array
+ {
+ $allResults = [];
+ $page = 1;
+
+ do {
+ $data['page'] = $page;
+ $data['per_page'] = 50;
+
+ $response = $this->request($method, $endpoint, $data);
+
+ if (isset($response[$resourceKey])) {
+ $allResults = array_merge($allResults, $response[$resourceKey]);
+ }
+
+ $nextPage = $response['meta']['pagination']['next_page'] ?? null;
+ $page = $nextPage;
+ } while ($nextPage !== null);
+
+ return $allResults;
+ }
+
+ public function getLocations(): array
+ {
+ return $this->requestPaginated('get', '/locations', 'locations');
+ }
+
+ public function getImages(): array
+ {
+ return $this->requestPaginated('get', '/images', 'images', [
+ 'type' => 'system',
+ ]);
+ }
+
+ public function getServerTypes(): array
+ {
+ return $this->requestPaginated('get', '/server_types', 'server_types');
+ }
+
+ public function getSshKeys(): array
+ {
+ return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
+ }
+
+ public function uploadSshKey(string $name, string $publicKey): array
+ {
+ $response = $this->request('post', '/ssh_keys', [
+ 'name' => $name,
+ 'public_key' => $publicKey,
+ ]);
+
+ return $response['ssh_key'] ?? [];
+ }
+
+ public function createServer(array $params): array
+ {
+ $response = $this->request('post', '/servers', $params);
+
+ return $response['server'] ?? [];
+ }
+
+ public function deleteServer(int $serverId): void
+ {
+ $this->request('delete', "/servers/{$serverId}");
+ }
+}
diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php
index e52bbcc16..e57354037 100644
--- a/database/migrations/2025_10_03_154100_update_clickhouse_image.php
+++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php
@@ -1,32 +1,32 @@
-string('image')->default('bitnamilegacy/clickhouse')->change();
- });
- // Optionally, update any existing rows with the old default to the new one
- DB::table('standalone_clickhouses')
- ->where('image', 'bitnami/clickhouse')
- ->update(['image' => 'bitnamilegacy/clickhouse']);
- }
-
- public function down()
- {
- Schema::table('standalone_clickhouses', function (Blueprint $table) {
- $table->string('image')->default('bitnami/clickhouse')->change();
- });
- // Optionally, revert any changed values
- DB::table('standalone_clickhouses')
- ->where('image', 'bitnamilegacy/clickhouse')
- ->update(['image' => 'bitnami/clickhouse']);
- }
-};
\ No newline at end of file
+string('image')->default('bitnamilegacy/clickhouse')->change();
+ });
+ // Optionally, update any existing rows with the old default to the new one
+ DB::table('standalone_clickhouses')
+ ->where('image', 'bitnami/clickhouse')
+ ->update(['image' => 'bitnamilegacy/clickhouse']);
+ }
+
+ public function down()
+ {
+ Schema::table('standalone_clickhouses', function (Blueprint $table) {
+ $table->string('image')->default('bitnami/clickhouse')->change();
+ });
+ // Optionally, revert any changed values
+ DB::table('standalone_clickhouses')
+ ->where('image', 'bitnamilegacy/clickhouse')
+ ->update(['image' => 'bitnami/clickhouse']);
+ }
+};
diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php
new file mode 100644
index 000000000..b1c9ec48b
--- /dev/null
+++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php
@@ -0,0 +1,28 @@
+bigInteger('hetzner_server_id')->nullable()->after('id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropColumn('hetzner_server_id');
+ });
+ }
+};
diff --git a/public/svgs/hetzner.svg b/public/svgs/hetzner.svg
new file mode 100644
index 000000000..68b1b868d
--- /dev/null
+++ b/public/svgs/hetzner.svg
@@ -0,0 +1,6 @@
+
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index 103f18316..46164840d 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -250,6 +250,18 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
{{ $checkbox['label'] }}
+ @if (isset($checkbox['default_warning']))
+
+
Loading Hetzner data...
+