From 6ed92cb97af582ee0f2cea2493b8e42dcefb69f9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:39:57 +0200 Subject: [PATCH] fix(vultr): validate public network and token scope Require IPv6 when public IPv4 is disabled, constrain cloud token lookup by team and provider during deletion, and encode Vultr instance IDs in API paths. --- app/Actions/Server/DeleteServer.php | 18 ++- app/Http/Controllers/Api/VultrController.php | 101 ++++++++++++- app/Livewire/Server/New/ByVultr.php | 14 ++ app/Services/VultrService.php | 11 +- openapi.json | 143 +++++++++++++++++- openapi.yaml | 94 +++++++++++- .../cloud-provider-token-form.blade.php | 4 +- tests/Feature/VultrApiTest.php | 19 +++ tests/Feature/VultrServerCreationTest.php | 15 ++ tests/Unit/VultrDeleteServerTest.php | 35 +++++ tests/Unit/VultrServiceTest.php | 14 ++ 11 files changed, 448 insertions(+), 20 deletions(-) diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index bbfff7abc..3e4bc6e5d 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -35,7 +35,7 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het ); } - ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part if (! $server) { @@ -59,7 +59,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider $token = null; if ($cloudProviderTokenId) { - $token = CloudProviderToken::find($cloudProviderTokenId); + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'hetzner') + ->first(); } if (! $token) { @@ -97,7 +100,10 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide $token = null; if ($cloudProviderTokenId) { - $token = CloudProviderToken::find($cloudProviderTokenId); + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'vultr') + ->first(); } if (! $token) { @@ -107,7 +113,7 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide } if (! $token) { - ray('No Vultr token found for team, skipping Vultr deletion', [ + logger()->debug('No Vultr token found for team, skipping Vultr deletion', [ 'team_id' => $teamId, 'vultr_instance_id' => $vultrInstanceId, ]); @@ -118,12 +124,12 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide $vultrService = new VultrService($token->token); $vultrService->deleteInstance($vultrInstanceId); - ray('Deleted server from Vultr', [ + logger()->debug('Deleted server from Vultr', [ 'vultr_instance_id' => $vultrInstanceId, 'team_id' => $teamId, ]); } catch (\Throwable $e) { - ray('Failed to delete server from Vultr', [ + logger()->error('Failed to delete server from Vultr', [ 'error' => $e->getMessage(), 'vultr_instance_id' => $vultrInstanceId, 'team_id' => $teamId, diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 2ea46041c..7aaba568e 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -15,6 +15,7 @@ use App\Services\VultrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use OpenApi\Attributes as OA; class VultrController extends Controller { @@ -54,7 +55,22 @@ private function getVultrToken(Request $request): CloudProviderToken|JsonRespons return $token; } - public function regions(Request $request) + #[OA\Get( + summary: 'Get Vultr Regions', + description: 'Get all available Vultr regions.', + path: '/vultr/regions', + operationId: 'get-vultr-regions', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr regions.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function regions(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -68,7 +84,22 @@ public function regions(Request $request) } } - public function plans(Request $request) + #[OA\Get( + summary: 'Get Vultr Plans', + description: 'Get all available Vultr plans.', + path: '/vultr/plans', + operationId: 'get-vultr-plans', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr plans.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function plans(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -82,7 +113,22 @@ public function plans(Request $request) } } - public function operatingSystems(Request $request) + #[OA\Get( + summary: 'Get Vultr Operating Systems', + description: 'Get all available Vultr operating systems.', + path: '/vultr/os', + operationId: 'get-vultr-operating-systems', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr operating systems.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function operatingSystems(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -96,7 +142,22 @@ public function operatingSystems(Request $request) } } - public function sshKeys(Request $request) + #[OA\Get( + summary: 'Get Vultr SSH Keys', + description: 'Get all Vultr SSH keys available to the selected token.', + path: '/vultr/ssh-keys', + operationId: 'get-vultr-ssh-keys', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function sshKeys(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -110,7 +171,23 @@ public function sshKeys(Request $request) } } - public function createServer(Request $request) + #[OA\Post( + summary: 'Create Vultr Server', + description: 'Create a Vultr instance and link it as a Coolify server.', + path: '/servers/vultr', + operationId: 'create-vultr-server', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 201, description: 'Vultr server created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse { $allowedFields = [ 'cloud_provider_token_uuid', @@ -187,6 +264,10 @@ public function createServer(Request $request) $request->offsetSet('instant_validate', false); } + if ($request->disable_public_ipv4 && ! $request->enable_ipv6) { + return $this->networkConfigurationErrorResponse(); + } + $token = CloudProviderToken::whereTeamId($teamId) ->whereUuid($this->getCloudProviderTokenUuid($request)) ->where('provider', 'vultr') @@ -308,4 +389,14 @@ private function normalizePublicKey(string $publicKey): string return implode(' ', array_slice($parts ?: [], 0, 2)); } + + private function networkConfigurationErrorResponse(): JsonResponse + { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'enable_ipv6' => ['Enable IPv6 when disabling public IPv4.'], + ], + ], 422); + } } diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 148968e6a..bc0b936d1 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -330,6 +330,9 @@ private function createVultrServer(string $token): array public function submit(): mixed { $this->validate(); + if (! $this->hasValidPublicNetworkConfiguration()) { + return null; + } try { $this->authorize('create', Server::class); @@ -414,4 +417,15 @@ private function normalizePublicKey(string $publicKey): string return implode(' ', array_slice($parts ?: [], 0, 2)); } + + private function hasValidPublicNetworkConfiguration(): bool + { + if (! $this->disable_public_ipv4 || $this->enable_ipv6) { + return true; + } + + $this->addError('enable_ipv6', 'Enable IPv6 when disabling public IPv4.'); + + return false; + } } diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index 6e1559f98..0e335d3e0 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -153,19 +153,19 @@ private function isUsableIp(?string $ip): bool public function getInstance(string $instanceId): array { - $response = $this->request('get', "/instances/{$instanceId}"); + $response = $this->request('get', $this->instanceEndpoint($instanceId)); return $response['instance'] ?? []; } public function startInstance(string $instanceId): array { - return $this->request('post', "/instances/{$instanceId}/start"); + return $this->request('post', $this->instanceEndpoint($instanceId).'/start'); } public function deleteInstance(string $instanceId): void { - $this->request('delete', "/instances/{$instanceId}"); + $this->request('delete', $this->instanceEndpoint($instanceId)); } public function getInstances(): array @@ -183,4 +183,9 @@ public function findInstanceByIp(string $ip): ?array return null; } + + private function instanceEndpoint(string $instanceId): string + { + return '/instances/'.rawurlencode($instanceId); + } } diff --git a/openapi.json b/openapi.json index 6e49729ce..c7aea585b 100644 --- a/openapi.json +++ b/openapi.json @@ -4040,7 +4040,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ] }, "team_id": { @@ -4098,7 +4099,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ], "example": "hetzner", "description": "The cloud provider." @@ -14156,6 +14158,139 @@ } ] } + }, + "\/vultr\/regions": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Regions", + "description": "Get all available Vultr regions.", + "operationId": "get-vultr-regions", + "responses": { + "200": { + "description": "List of Vultr regions." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/plans": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Plans", + "description": "Get all available Vultr plans.", + "operationId": "get-vultr-plans", + "responses": { + "200": { + "description": "List of Vultr plans." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/os": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Operating Systems", + "description": "Get all available Vultr operating systems.", + "operationId": "get-vultr-operating-systems", + "responses": { + "200": { + "description": "List of Vultr operating systems." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/ssh-keys": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr SSH Keys", + "description": "Get all Vultr SSH keys available to the selected token.", + "operationId": "get-vultr-ssh-keys", + "responses": { + "200": { + "description": "List of Vultr SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/vultr": { + "post": { + "tags": [ + "Vultr" + ], + "summary": "Create Vultr Server", + "description": "Create a Vultr instance and link it as a Coolify server.", + "operationId": "create-vultr-server", + "responses": { + "201": { + "description": "Vultr server created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Vultr API rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -15458,6 +15593,10 @@ { "name": "Teams", "description": "Teams" + }, + { + "name": "Vultr", + "description": "Vultr" } ] } diff --git a/openapi.yaml b/openapi.yaml index dd97873a2..8df2a7576 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2577,7 +2577,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -2605,7 +2605,7 @@ paths: properties: provider: type: string - enum: [hetzner, digitalocean] + enum: [hetzner, digitalocean, vultr] example: hetzner description: 'The cloud provider.' token: @@ -8984,6 +8984,93 @@ paths: security: - bearerAuth: [] + /vultr/regions: + get: + tags: + - Vultr + summary: 'Get Vultr Regions' + description: 'Get all available Vultr regions.' + operationId: get-vultr-regions + responses: + '200': + description: 'List of Vultr regions.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/plans: + get: + tags: + - Vultr + summary: 'Get Vultr Plans' + description: 'Get all available Vultr plans.' + operationId: get-vultr-plans + responses: + '200': + description: 'List of Vultr plans.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/os: + get: + tags: + - Vultr + summary: 'Get Vultr Operating Systems' + description: 'Get all available Vultr operating systems.' + operationId: get-vultr-operating-systems + responses: + '200': + description: 'List of Vultr operating systems.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/ssh-keys: + get: + tags: + - Vultr + summary: 'Get Vultr SSH Keys' + description: 'Get all Vultr SSH keys available to the selected token.' + operationId: get-vultr-ssh-keys + responses: + '200': + description: 'List of Vultr SSH keys.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /servers/vultr: + post: + tags: + - Vultr + summary: 'Create Vultr Server' + description: 'Create a Vultr instance and link it as a Coolify server.' + operationId: create-vultr-server + responses: + '201': + description: 'Vultr server created.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'Vultr API rate limit exceeded.' + security: + - + bearerAuth: [] components: schemas: Application: @@ -9925,3 +10012,6 @@ tags: - name: Teams description: Teams + - + name: Vultr + description: Vultr diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 56c53d869..fed391463 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -33,7 +33,7 @@ class='underline dark:text-white'>Sign up here @if ($provider === 'vultr') Open Account → API Access.

- Don't have a Vultr account? Sign up here
Coolify's affiliate link, only new accounts - supports us @@ -74,7 +74,7 @@ class='underline dark:text-white'>Sign up here @if ($provider === 'vultr') Open Account → API Access.

- Don't have a Vultr account? Sign up here
Coolify's affiliate link, only new accounts - supports us diff --git a/tests/Feature/VultrApiTest.php b/tests/Feature/VultrApiTest.php index 788f3bfb5..8a96ec685 100644 --- a/tests/Feature/VultrApiTest.php +++ b/tests/Feature/VultrApiTest.php @@ -371,4 +371,23 @@ function testPublicKey(): string $response->assertStatus(201); $response->assertJsonFragment(['ip' => '2001:db8::1']); }); + + test('requires IPv6 when public IPv4 is disabled', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv6' => false, + 'disable_public_ipv4' => true, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['enable_ipv6']); + }); }); diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php index 6f8166ee3..5dee12ca2 100644 --- a/tests/Feature/VultrServerCreationTest.php +++ b/tests/Feature/VultrServerCreationTest.php @@ -133,3 +133,18 @@ function vultrLivewireTestPublicKey(): string && $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl"); }); }); + +it('requires IPv6 when public IPv4 is disabled', function () { + Livewire::test(ByVultr::class) + ->set('selected_token_id', $this->vultrToken->id) + ->set('current_step', 2) + ->set('server_name', 'test-vultr-server') + ->set('selected_region', 'ewr') + ->set('selected_plan', 'vc2-1c-1gb') + ->set('selected_os_id', 2284) + ->set('private_key_id', $this->privateKey->id) + ->set('enable_ipv6', false) + ->set('disable_public_ipv4', true) + ->call('submit') + ->assertHasErrors(['enable_ipv6']); +}); diff --git a/tests/Unit/VultrDeleteServerTest.php b/tests/Unit/VultrDeleteServerTest.php index cda628d16..05891eed7 100644 --- a/tests/Unit/VultrDeleteServerTest.php +++ b/tests/Unit/VultrDeleteServerTest.php @@ -81,3 +81,38 @@ function vultrDeleteTestPrivateKey(): string expect(Server::withTrashed()->find($server->id))->toBeNull(); }); + +it('does not use another team Vultr token when deleting an instance', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204), + ]); + + $otherTeam = Team::factory()->create(); + $otherToken = CloudProviderToken::create([ + 'team_id' => $otherTeam->id, + 'provider' => 'vultr', + 'token' => 'other-team-vultr-token', + 'name' => 'Other Team Vultr Token', + ]); + + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->vultrToken->id, + 'vultr_instance_id' => 'instance-1', + ]); + + $server->delete(); + + DeleteServer::run( + serverId: $server->id, + cloudProviderTokenId: $otherToken->id, + teamId: $this->team->id, + deleteFromVultr: true, + vultrInstanceId: 'instance-1' + ); + + $request = Http::recorded()->first()[0]; + + expect($request->header('Authorization'))->toBe(['Bearer test-vultr-token']); +}); diff --git a/tests/Unit/VultrServiceTest.php b/tests/Unit/VultrServiceTest.php index 67f13f5a3..a912b8907 100644 --- a/tests/Unit/VultrServiceTest.php +++ b/tests/Unit/VultrServiceTest.php @@ -150,3 +150,17 @@ Http::assertSentCount(2); }); + +it('encodes instance IDs in request paths', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance%2F1' => Http::response([ + 'instance' => ['id' => 'instance/1'], + ], 200), + ]); + + $service = new VultrService('fake-token'); + + expect($service->getInstance('instance/1')['id'])->toBe('instance/1'); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance%2F1'); +});