From 67b1db925460d21351babd9896b12de2b837879b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:14:32 +0100 Subject: [PATCH] feat: add Hetzner Cloud server linking for manually-added servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow manually-added servers to be linked to Hetzner Cloud instances by matching IP address. Once linked, servers gain power controls and status monitoring. Changes: - Add getServers() and findServerByIp() methods to HetznerService - Add Hetzner linking UI section to Server General page - Add unit tests for new HetznerService methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Livewire/Server/Show.php | 109 +++++++++++++ app/Services/HetznerService.php | 26 +++ .../views/livewire/server/show.blade.php | 58 +++++++ tests/Unit/HetznerServiceTest.php | 152 ++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 tests/Unit/HetznerServiceTest.php diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 4626a9135..7a4a1c480 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -5,9 +5,12 @@ use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Events\ServerReachabilityChanged; +use App\Models\CloudProviderToken; use App\Models\Server; +use App\Services\HetznerService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -73,6 +76,17 @@ class Show extends Component public bool $isValidating = false; + // Hetzner linking properties + public Collection $availableHetznerTokens; + + public ?int $selectedHetznerTokenId = null; + + public ?array $matchedHetznerServer = null; + + public ?string $hetznerSearchError = null; + + public bool $hetznerNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -150,6 +164,9 @@ public function mount(string $server_uuid) $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->isValidating = $this->server->is_validating ?? false; + // Load Hetzner tokens for linking + $this->loadHetznerTokens(); + } catch (\Throwable $e) { return handleError($e, $this); } @@ -465,6 +482,98 @@ public function submit() } } + public function loadHetznerTokens(): void + { + $this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function searchHetznerServer(): void + { + $this->hetznerSearchError = null; + $this->hetznerNoMatchFound = false; + $this->matchedHetznerServer = null; + + if (! $this->selectedHetznerTokenId) { + $this->hetznerSearchError = 'Please select a Hetzner token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId); + if (! $token) { + $this->hetznerSearchError = 'Invalid token selected.'; + + return; + } + + $hetznerService = new HetznerService($token->token); + $matched = $hetznerService->findServerByIp($this->server->ip); + + if ($matched) { + $this->matchedHetznerServer = $matched; + } else { + $this->hetznerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage(); + } + } + + public function linkToHetzner() + { + if (! $this->matchedHetznerServer) { + $this->dispatch('error', 'No Hetzner server selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + // Verify the server exists and is accessible with the token + $hetznerService = new HetznerService($token->token); + $serverData = $hetznerService->getServer($this->matchedHetznerServer['id']); + + if (empty($serverData)) { + $this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']); + + return; + } + + // Update the server with Hetzner details + $this->server->update([ + 'cloud_provider_token_id' => $this->selectedHetznerTokenId, + 'hetzner_server_id' => $this->matchedHetznerServer['id'], + 'hetzner_server_status' => $serverData['status'] ?? null, + ]); + + $this->hetznerServerStatus = $serverData['status'] ?? null; + + // Clear the linking state + $this->matchedHetznerServer = null; + $this->selectedHetznerTokenId = null; + $this->hetznerNoMatchFound = false; + $this->hetznerSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.server.show'); diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index f7855090a..1de7eb2b1 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -161,4 +161,30 @@ public function deleteServer(int $serverId): void { $this->request('delete', "/servers/{$serverId}"); } + + public function getServers(): array + { + return $this->requestPaginated('get', '/servers', 'servers'); + } + + public function findServerByIp(string $ip): ?array + { + $servers = $this->getServers(); + + foreach ($servers as $server) { + // Check IPv4 + $ipv4 = data_get($server, 'public_net.ipv4.ip'); + if ($ipv4 === $ip) { + return $server; + } + + // Check IPv6 (Hetzner returns the full /64 block) + $ipv6 = data_get($server, 'public_net.ipv6.ip'); + if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) { + return $server; + } + } + + return null; + } } diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index f9311bb83..a8344df05 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -320,6 +320,64 @@ class="w-full input opacity-50 cursor-not-allowed" + @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) +
+

Link to Hetzner Cloud

+

+ Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. +

+ +
+
+ + + @foreach ($availableHetznerTokens as $token) + + @endforeach + +
+ + Search by IP + Searching... + +
+ + @if ($hetznerSearchError) +
+

{{ $hetznerSearchError }}

+
+ @endif + + @if ($hetznerNoMatchFound) +
+

+ No Hetzner server found matching IP: {{ $server->ip }} +

+

+ Try a different token or verify the server IP is correct. +

+
+ @endif + + @if ($matchedHetznerServer) +
+

Match Found!

+
+
Name: {{ $matchedHetznerServer['name'] }}
+
ID: {{ $matchedHetznerServer['id'] }}
+
Status: {{ ucfirst($matchedHetznerServer['status']) }}
+
Type: {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}
+
+ + Link This Server + +
+ @endif +
+ @endif @if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
diff --git a/tests/Unit/HetznerServiceTest.php b/tests/Unit/HetznerServiceTest.php new file mode 100644 index 000000000..7e76efdec --- /dev/null +++ b/tests/Unit/HetznerServiceTest.php @@ -0,0 +1,152 @@ + Http::response([ + 'servers' => [ + [ + 'id' => 12345, + 'name' => 'test-server-1', + 'status' => 'running', + 'public_net' => [ + 'ipv4' => ['ip' => '123.45.67.89'], + 'ipv6' => ['ip' => '2a01:4f8::/64'], + ], + ], + [ + 'id' => 67890, + 'name' => 'test-server-2', + 'status' => 'off', + 'public_net' => [ + 'ipv4' => ['ip' => '98.76.54.32'], + 'ipv6' => ['ip' => '2a01:4f9::/64'], + ], + ], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $service = new HetznerService('fake-token'); + $servers = $service->getServers(); + + expect($servers)->toBeArray() + ->and(count($servers))->toBe(2) + ->and($servers[0]['id'])->toBe(12345) + ->and($servers[1]['id'])->toBe(67890); +}); + +it('findServerByIp returns matching server by IPv4', function () { + Http::fake([ + 'api.hetzner.cloud/v1/servers*' => Http::response([ + 'servers' => [ + [ + 'id' => 12345, + 'name' => 'test-server', + 'status' => 'running', + 'public_net' => [ + 'ipv4' => ['ip' => '123.45.67.89'], + 'ipv6' => ['ip' => '2a01:4f8::/64'], + ], + ], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $service = new HetznerService('fake-token'); + $result = $service->findServerByIp('123.45.67.89'); + + expect($result)->not->toBeNull() + ->and($result['id'])->toBe(12345) + ->and($result['name'])->toBe('test-server'); +}); + +it('findServerByIp returns null when no match', function () { + Http::fake([ + 'api.hetzner.cloud/v1/servers*' => Http::response([ + 'servers' => [ + [ + 'id' => 12345, + 'name' => 'test-server', + 'status' => 'running', + 'public_net' => [ + 'ipv4' => ['ip' => '123.45.67.89'], + 'ipv6' => ['ip' => '2a01:4f8::/64'], + ], + ], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $service = new HetznerService('fake-token'); + $result = $service->findServerByIp('1.2.3.4'); + + expect($result)->toBeNull(); +}); + +it('findServerByIp returns null when server list is empty', function () { + Http::fake([ + 'api.hetzner.cloud/v1/servers*' => Http::response([ + 'servers' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $service = new HetznerService('fake-token'); + $result = $service->findServerByIp('123.45.67.89'); + + expect($result)->toBeNull(); +}); + +it('findServerByIp matches correct server among multiple', function () { + Http::fake([ + 'api.hetzner.cloud/v1/servers*' => Http::response([ + 'servers' => [ + [ + 'id' => 11111, + 'name' => 'server-a', + 'status' => 'running', + 'public_net' => [ + 'ipv4' => ['ip' => '10.0.0.1'], + 'ipv6' => ['ip' => '2a01:4f8::/64'], + ], + ], + [ + 'id' => 22222, + 'name' => 'server-b', + 'status' => 'running', + 'public_net' => [ + 'ipv4' => ['ip' => '10.0.0.2'], + 'ipv6' => ['ip' => '2a01:4f9::/64'], + ], + ], + [ + 'id' => 33333, + 'name' => 'server-c', + 'status' => 'off', + 'public_net' => [ + 'ipv4' => ['ip' => '10.0.0.3'], + 'ipv6' => ['ip' => '2a01:4fa::/64'], + ], + ], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $service = new HetznerService('fake-token'); + $result = $service->findServerByIp('10.0.0.2'); + + expect($result)->not->toBeNull() + ->and($result['id'])->toBe(22222) + ->and($result['name'])->toBe('server-b'); +});