feat(hetzner): add endpoints and UI for attaching firewalls and internal networks to the Server Creation dialog

This commit is contained in:
Jan Thiel 2026-04-17 12:41:10 +02:00
parent 3a8f52ce16
commit a4b27641c8
7 changed files with 516 additions and 1 deletions

View file

@ -454,6 +454,193 @@ 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);
}
try {
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getFirewalls());
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch firewalls: '.$e->getMessage()], 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);
}
try {
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getNetworks());
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch networks: '.$e->getMessage()], 500);
}
}
#[OA\Post(
summary: 'Create Hetzner Server',
description: 'Create a new server on Hetzner and register it in Coolify.',
@ -482,6 +669,8 @@ public function sshKeys(Request $request)
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
'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'],
],
@ -540,6 +729,8 @@ public function createServer(Request $request)
'enable_ipv4',
'enable_ipv6',
'hetzner_ssh_key_ids',
'hetzner_firewall_ids',
'hetzner_network_ids',
'cloud_init_script',
'instant_validate',
];
@ -566,6 +757,10 @@ public function createServer(Request $request)
'enable_ipv6' => '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',
]);
@ -604,6 +799,12 @@ public function createServer(Request $request)
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);
}
@ -679,6 +880,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;

View file

@ -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;
@ -112,6 +120,9 @@ public function resetSelection()
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
$this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = [];
}
public function loadTokens()
@ -160,6 +171,10 @@ 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',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
@ -241,6 +256,9 @@ public function previousStep()
private function loadHetznerData(string $token)
{
$this->loading_data = true;
$this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = [];
try {
$hetznerService = new HetznerService($token);
@ -270,6 +288,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;
@ -345,6 +371,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) {
@ -367,6 +424,13 @@ 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)
@ -454,6 +518,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;

View file

@ -117,6 +117,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', [

View file

@ -149,6 +149,37 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
</x-forms.datalist>
</div>
<div>
<x-forms.datalist label="Firewalls (from Hetzner)" id="selectedHetznerFirewallIds"
helper="Optionally apply existing Hetzner firewalls when the server is created."
:multiple="true" :disabled="count($hetznerFirewalls) === 0" :placeholder="count($hetznerFirewalls) > 0
? 'Search and select firewalls...'
: 'No firewalls found in Hetzner account'">
@foreach ($hetznerFirewalls as $firewall)
<option value="{{ $firewall['id'] }}">
{{ $firewall['name'] }}
@if (isset($firewall['rules']))
- {{ count($firewall['rules']) }} rules
@endif
</option>
@endforeach
</x-forms.datalist>
</div>
<div>
<x-forms.datalist label="Internal Networks (from Hetzner)" id="selectedHetznerNetworkIds"
helper="Optionally attach one or more private networks. Networks are filtered to the selected location's network zone when possible."
:multiple="true" :disabled="count($this->availableNetworks) === 0" :placeholder="count($this->availableNetworks) > 0
? 'Search and select networks...'
: 'No compatible networks found'">
@foreach ($this->availableNetworks as $network)
<option value="{{ $network['id'] }}">
{{ $network['name'] }} - {{ $network['ip_range'] }}
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Network Configuration</label>
<div class="flex gap-4">
@ -201,4 +232,4 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
@endif
@endif
@endif
</div>
</div>

View file

@ -94,6 +94,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']);

View file

@ -4,6 +4,7 @@
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
@ -210,6 +211,52 @@
});
});
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']);
});
});
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']);
});
});
describe('POST /api/v1/servers/hetzner', function () {
test('creates a Hetzner server', function () {
// Mock Hetzner API calls
@ -418,6 +465,53 @@
$response->assertJsonFragment(['ip' => '2001:db8::1']);
});
test('passes selected firewalls and networks to Hetzner server creation', function () {
Http::fake([
'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,
'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,

View file

@ -1,9 +1,12 @@
<?php
use App\Livewire\Server\New\ByHetzner;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
// Note: Full Livewire integration tests require database setup
@ -141,6 +144,15 @@
->and(count($sshKeys))->toBe(3);
});
it('validates network array merging removes duplicate Hetzner network ids', function () {
$selectedHetznerNetworkIds = [456, 789, 456];
$networkIds = array_values(array_unique($selectedHetznerNetworkIds));
expect($networkIds)->toBe([456, 789])
->and(count($networkIds))->toBe(2);
});
describe('Boarding Flow Integration', function () {
uses(RefreshDatabase::class);
@ -186,3 +198,80 @@
expect($this->team->fresh()->show_boarding)->toBeTrue();
});
});
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',
]);
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', '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);
});
});