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.
This commit is contained in:
Andras Bacsai 2026-07-07 21:39:57 +02:00
parent 31f904ef9f
commit 6ed92cb97a
11 changed files with 448 additions and 20 deletions

View file

@ -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 is already deleted from Coolify, skip this part
if (! $server) { if (! $server) {
@ -59,7 +59,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
$token = null; $token = null;
if ($cloudProviderTokenId) { if ($cloudProviderTokenId) {
$token = CloudProviderToken::find($cloudProviderTokenId); $token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'hetzner')
->first();
} }
if (! $token) { if (! $token) {
@ -97,7 +100,10 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide
$token = null; $token = null;
if ($cloudProviderTokenId) { if ($cloudProviderTokenId) {
$token = CloudProviderToken::find($cloudProviderTokenId); $token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'vultr')
->first();
} }
if (! $token) { if (! $token) {
@ -107,7 +113,7 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide
} }
if (! $token) { 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, 'team_id' => $teamId,
'vultr_instance_id' => $vultrInstanceId, 'vultr_instance_id' => $vultrInstanceId,
]); ]);
@ -118,12 +124,12 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide
$vultrService = new VultrService($token->token); $vultrService = new VultrService($token->token);
$vultrService->deleteInstance($vultrInstanceId); $vultrService->deleteInstance($vultrInstanceId);
ray('Deleted server from Vultr', [ logger()->debug('Deleted server from Vultr', [
'vultr_instance_id' => $vultrInstanceId, 'vultr_instance_id' => $vultrInstanceId,
'team_id' => $teamId, 'team_id' => $teamId,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
ray('Failed to delete server from Vultr', [ logger()->error('Failed to delete server from Vultr', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'vultr_instance_id' => $vultrInstanceId, 'vultr_instance_id' => $vultrInstanceId,
'team_id' => $teamId, 'team_id' => $teamId,

View file

@ -15,6 +15,7 @@
use App\Services\VultrService; use App\Services\VultrService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class VultrController extends Controller class VultrController extends Controller
{ {
@ -54,7 +55,22 @@ private function getVultrToken(Request $request): CloudProviderToken|JsonRespons
return $token; 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); $token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) { 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); $token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) { 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); $token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) { 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); $token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) { 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 = [ $allowedFields = [
'cloud_provider_token_uuid', 'cloud_provider_token_uuid',
@ -187,6 +264,10 @@ public function createServer(Request $request)
$request->offsetSet('instant_validate', false); $request->offsetSet('instant_validate', false);
} }
if ($request->disable_public_ipv4 && ! $request->enable_ipv6) {
return $this->networkConfigurationErrorResponse();
}
$token = CloudProviderToken::whereTeamId($teamId) $token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($this->getCloudProviderTokenUuid($request)) ->whereUuid($this->getCloudProviderTokenUuid($request))
->where('provider', 'vultr') ->where('provider', 'vultr')
@ -308,4 +389,14 @@ private function normalizePublicKey(string $publicKey): string
return implode(' ', array_slice($parts ?: [], 0, 2)); 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);
}
} }

View file

@ -330,6 +330,9 @@ private function createVultrServer(string $token): array
public function submit(): mixed public function submit(): mixed
{ {
$this->validate(); $this->validate();
if (! $this->hasValidPublicNetworkConfiguration()) {
return null;
}
try { try {
$this->authorize('create', Server::class); $this->authorize('create', Server::class);
@ -414,4 +417,15 @@ private function normalizePublicKey(string $publicKey): string
return implode(' ', array_slice($parts ?: [], 0, 2)); 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;
}
} }

View file

@ -153,19 +153,19 @@ private function isUsableIp(?string $ip): bool
public function getInstance(string $instanceId): array public function getInstance(string $instanceId): array
{ {
$response = $this->request('get', "/instances/{$instanceId}"); $response = $this->request('get', $this->instanceEndpoint($instanceId));
return $response['instance'] ?? []; return $response['instance'] ?? [];
} }
public function startInstance(string $instanceId): array 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 public function deleteInstance(string $instanceId): void
{ {
$this->request('delete', "/instances/{$instanceId}"); $this->request('delete', $this->instanceEndpoint($instanceId));
} }
public function getInstances(): array public function getInstances(): array
@ -183,4 +183,9 @@ public function findInstanceByIp(string $ip): ?array
return null; return null;
} }
private function instanceEndpoint(string $instanceId): string
{
return '/instances/'.rawurlencode($instanceId);
}
} }

View file

@ -4040,7 +4040,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"hetzner", "hetzner",
"digitalocean" "digitalocean",
"vultr"
] ]
}, },
"team_id": { "team_id": {
@ -4098,7 +4099,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"hetzner", "hetzner",
"digitalocean" "digitalocean",
"vultr"
], ],
"example": "hetzner", "example": "hetzner",
"description": "The cloud provider." "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": { "components": {
@ -15458,6 +15593,10 @@
{ {
"name": "Teams", "name": "Teams",
"description": "Teams" "description": "Teams"
},
{
"name": "Vultr",
"description": "Vultr"
} }
] ]
} }

View file

@ -2577,7 +2577,7 @@ paths:
schema: schema:
type: array type: array
items: 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 type: object
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
@ -2605,7 +2605,7 @@ paths:
properties: properties:
provider: provider:
type: string type: string
enum: [hetzner, digitalocean] enum: [hetzner, digitalocean, vultr]
example: hetzner example: hetzner
description: 'The cloud provider.' description: 'The cloud provider.'
token: token:
@ -8984,6 +8984,93 @@ paths:
security: security:
- -
bearerAuth: [] 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: components:
schemas: schemas:
Application: Application:
@ -9925,3 +10012,6 @@ tags:
- -
name: Teams name: Teams
description: Teams description: Teams
-
name: Vultr
description: Vultr

View file

@ -33,7 +33,7 @@ class='underline dark:text-white'>Sign up here</a>
@if ($provider === 'vultr') @if ($provider === 'vultr')
Open Account API Access. Open Account API Access.
<br><br> <br><br>
Don't have a Vultr account? <a href='https://www.vultr.com/register-steps/' target='_blank' Don't have a Vultr account? <a href='https://www.vultr.com/?ref=9911335' target='_blank'
class='underline dark:text-white'>Sign up here</a> class='underline dark:text-white'>Sign up here</a>
<br> <br>
<span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span> <span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span>
@ -74,7 +74,7 @@ class='underline dark:text-white'>Sign up here</a>
@if ($provider === 'vultr') @if ($provider === 'vultr')
Open Account API Access. Open Account API Access.
<br><br> <br><br>
Don't have a Vultr account? <a href='https://www.vultr.com/register-steps/' target='_blank' Don't have a Vultr account? <a href='https://www.vultr.com/?ref=9911335' target='_blank'
class='underline dark:text-white'>Sign up here</a> class='underline dark:text-white'>Sign up here</a>
<br> <br>
<span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span> <span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span>

View file

@ -371,4 +371,23 @@ function testPublicKey(): string
$response->assertStatus(201); $response->assertStatus(201);
$response->assertJsonFragment(['ip' => '2001:db8::1']); $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']);
});
}); });

View file

@ -133,3 +133,18 @@ function vultrLivewireTestPublicKey(): string
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl"); && $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']);
});

View file

@ -81,3 +81,38 @@ function vultrDeleteTestPrivateKey(): string
expect(Server::withTrashed()->find($server->id))->toBeNull(); 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']);
});

View file

@ -150,3 +150,17 @@
Http::assertSentCount(2); 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');
});