mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Compare commits
9 commits
8abf716d13
...
2cb831dbde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb831dbde | ||
|
|
4015e03153 | ||
|
|
d0929a5883 | ||
|
|
86cbd82991 | ||
|
|
b8d57bfd3c | ||
|
|
0ca5596b1f | ||
|
|
91f538e171 | ||
|
|
4f39cf6dc8 | ||
|
|
c658fc9a03 |
10 changed files with 1010 additions and 71 deletions
|
|
@ -57,7 +57,9 @@ Thank you so much!
|
|||
|
||||
### Huge Sponsors
|
||||
|
||||
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
|
||||
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
|
||||
*
|
||||
|
||||
### Big Sponsors
|
||||
|
||||
|
|
@ -85,7 +87,6 @@ Thank you so much!
|
|||
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
|
||||
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
|
||||
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
|
||||
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
|
||||
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
|
||||
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
|
||||
|
|
@ -96,6 +97,7 @@ Thank you so much!
|
|||
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
|
||||
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
|
||||
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
||||
* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions
|
||||
|
||||
|
||||
### Small Sponsors
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Jobs\DeleteResourceJob;
|
|||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
|
|
@ -3525,6 +3526,602 @@ class ApplicationsController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Storages',
|
||||
description: 'List all persistent storages by application UUID.',
|
||||
path: '/applications/{uuid}/storages',
|
||||
operationId: 'list-storages-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All persistent storages by application UUID.',
|
||||
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'],
|
||||
'mount_path' => ['type' => 'string'],
|
||||
'host_path' => ['type' => 'string', 'nullable' => true],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
],
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function storages(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json([
|
||||
'message' => 'Application not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $application);
|
||||
|
||||
$storages = $application->persistentStorages->sortBy('id')->values();
|
||||
|
||||
return response()->json($storages);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Storage',
|
||||
description: 'Create persistent storage for an application by UUID.',
|
||||
path: '/applications/{uuid}/storages',
|
||||
operationId: 'create-storage-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Storage created.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['name', 'mount_path'],
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the storage volume (will be prefixed with app UUID).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The mount path inside the container.'],
|
||||
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path on the server. If not set, a Docker named volume is used.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Persistent storage created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'mount_path' => ['type' => 'string'],
|
||||
'host_path' => ['type' => 'string', 'nullable' => true],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Storage with this name already exists.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Storage with this name already exists.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_storage(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'mount_path', 'host_path'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json([
|
||||
'message' => 'Application not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|required',
|
||||
'mount_path' => 'string|required',
|
||||
'host_path' => 'string|nullable',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$name = $application->uuid.'-'.str($request->name)->trim()->value;
|
||||
|
||||
$existing = LocalPersistentVolume::where('name', $name)
|
||||
->where('resource_id', $application->id)
|
||||
->where('resource_type', $application->getMorphClass())
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'message' => 'Storage with this name already exists.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$storage = LocalPersistentVolume::create([
|
||||
'name' => $name,
|
||||
'mount_path' => $request->mount_path,
|
||||
'host_path' => $request->host_path,
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $storage->id,
|
||||
'name' => $storage->name,
|
||||
'mount_path' => $storage->mount_path,
|
||||
'host_path' => $storage->host_path,
|
||||
])->setStatusCode(201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Storage',
|
||||
description: 'Get persistent storage by ID for an application.',
|
||||
path: '/applications/{uuid}/storages/{storage_id}',
|
||||
operationId: 'get-storage-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'storage_id',
|
||||
in: 'path',
|
||||
description: 'ID of the persistent storage.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'integer',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Persistent storage details.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'mount_path' => ['type' => 'string'],
|
||||
'host_path' => ['type' => 'string', 'nullable' => true],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function storage_by_id(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json([
|
||||
'message' => 'Application not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $application);
|
||||
|
||||
$storage = LocalPersistentVolume::where('id', $request->storage_id)
|
||||
->where('resource_id', $application->id)
|
||||
->where('resource_type', $application->getMorphClass())
|
||||
->first();
|
||||
|
||||
if (! $storage) {
|
||||
return response()->json([
|
||||
'message' => 'Storage not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json($storage);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Storage',
|
||||
description: 'Update persistent storage by ID for an application.',
|
||||
path: '/applications/{uuid}/storages/{storage_id}',
|
||||
operationId: 'update-storage-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'storage_id',
|
||||
in: 'path',
|
||||
description: 'ID of the persistent storage.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'integer',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Storage updated.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the storage volume (will be prefixed with app UUID).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The mount path inside the container.'],
|
||||
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path on the server. If not set, a Docker named volume is used.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Persistent storage updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'mount_path' => ['type' => 'string'],
|
||||
'host_path' => ['type' => 'string', 'nullable' => true],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Storage with this name already exists.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Storage with this name already exists.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_storage(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'mount_path', 'host_path'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json([
|
||||
'message' => 'Application not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$storage = LocalPersistentVolume::where('id', $request->storage_id)
|
||||
->where('resource_id', $application->id)
|
||||
->where('resource_type', $application->getMorphClass())
|
||||
->first();
|
||||
|
||||
if (! $storage) {
|
||||
return response()->json([
|
||||
'message' => 'Storage not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string',
|
||||
'mount_path' => 'string',
|
||||
'host_path' => 'string|nullable',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->has('name')) {
|
||||
$newName = $application->uuid.'-'.str($request->name)->trim()->value;
|
||||
|
||||
$existing = LocalPersistentVolume::where('name', $newName)
|
||||
->where('resource_id', $application->id)
|
||||
->where('resource_type', $application->getMorphClass())
|
||||
->where('id', '!=', $storage->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'message' => 'Storage with this name already exists.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$storage->name = $newName;
|
||||
}
|
||||
|
||||
if ($request->has('mount_path')) {
|
||||
$storage->mount_path = $request->mount_path;
|
||||
}
|
||||
|
||||
if ($request->has('host_path')) {
|
||||
$storage->host_path = $request->host_path;
|
||||
}
|
||||
|
||||
$storage->save();
|
||||
|
||||
return response()->json([
|
||||
'id' => $storage->id,
|
||||
'name' => $storage->name,
|
||||
'mount_path' => $storage->mount_path,
|
||||
'host_path' => $storage->host_path,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Storage',
|
||||
description: 'Delete persistent storage by ID for an application.',
|
||||
path: '/applications/{uuid}/storages/{storage_id}',
|
||||
operationId: 'delete-storage-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'storage_id',
|
||||
in: 'path',
|
||||
description: 'ID of the persistent storage.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'integer',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Persistent storage deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Storage deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_storage(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json([
|
||||
'message' => 'Application not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$storage = LocalPersistentVolume::where('id', $request->storage_id)
|
||||
->where('resource_id', $application->id)
|
||||
->where('resource_type', $application->getMorphClass())
|
||||
->first();
|
||||
|
||||
if (! $storage) {
|
||||
return response()->json([
|
||||
'message' => 'Storage not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$storage->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Storage deleted.',
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Start',
|
||||
description: 'Start application. `Post` request is also accepted.',
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$server->forceDisableServer();
|
||||
$this->team->notify(new ForceDisabled($server));
|
||||
});
|
||||
} elseif ($number_of_servers_to_disable === 0) {
|
||||
} elseif ($number_of_servers_to_disable <= 0) {
|
||||
$servers->each(function ($server) {
|
||||
if ($server->isForceDisabled()) {
|
||||
$server->forceEnableServer();
|
||||
|
|
|
|||
|
|
@ -95,7 +95,9 @@ class Advanced extends Component
|
|||
// Check if it's valid CIDR notation
|
||||
if (str_contains($entry, '/')) {
|
||||
[$ip, $mask] = explode('/', $entry);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) {
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$maxMask = $isIpv6 ? 128 : 32;
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) {
|
||||
return $entry;
|
||||
}
|
||||
$invalidEntries[] = $entry;
|
||||
|
|
@ -111,7 +113,7 @@ class Advanced extends Component
|
|||
$invalidEntries[] = $entry;
|
||||
|
||||
return null;
|
||||
})->filter()->unique();
|
||||
})->filter()->values()->all();
|
||||
|
||||
if (! empty($invalidEntries)) {
|
||||
$this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries));
|
||||
|
|
@ -119,13 +121,15 @@ class Advanced extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
if ($validEntries->isEmpty()) {
|
||||
if (empty($validEntries)) {
|
||||
$this->dispatch('error', 'No valid IP addresses or subnets provided');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->allowed_ips = $validEntries->implode(',');
|
||||
$validEntries = deduplicateAllowlist($validEntries);
|
||||
|
||||
$this->allowed_ips = implode(',', $validEntries);
|
||||
}
|
||||
|
||||
$this->instantSave();
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ class ValidIpOrCidr implements ValidationRule
|
|||
|
||||
[$ip, $mask] = $parts;
|
||||
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > 32) {
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$maxMask = $isIpv6 ? 128 : 32;
|
||||
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) {
|
||||
$invalidEntries[] = $entry;
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -127,44 +127,10 @@ function connectProxyToNetworks(Server $server)
|
|||
return $commands->flatten();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate shell commands to fix a Docker network that has an IPv6 gateway with CIDR notation.
|
||||
*
|
||||
* Docker 25+ may store IPv6 gateways with CIDR (e.g. fd7d:f7d2:7e77::1/64), which causes
|
||||
* ParseAddr errors in Docker Compose. This detects the issue and recreates the network.
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/8649
|
||||
*
|
||||
* @param string $network Network name to check and fix
|
||||
* @return array Shell commands to execute on the remote server
|
||||
*/
|
||||
function fixNetworkIpv6CidrGateway(string $network): array
|
||||
{
|
||||
return [
|
||||
"if docker network inspect {$network} >/dev/null 2>&1; then",
|
||||
" IPV6_GW=\$(docker network inspect {$network} --format '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\n' | grep '/' || true)",
|
||||
' if [ -n "$IPV6_GW" ]; then',
|
||||
" echo \"Fixing network {$network}: IPv6 gateway has CIDR notation (\$IPV6_GW)\"",
|
||||
" CONTAINERS=\$(docker network inspect {$network} --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null)",
|
||||
' for c in $CONTAINERS; do',
|
||||
" [ -n \"\$c\" ] && docker network disconnect {$network} \"\$c\" 2>/dev/null || true",
|
||||
' done',
|
||||
" docker network rm {$network} 2>/dev/null || true",
|
||||
" docker network create --attachable {$network} 2>/dev/null || true",
|
||||
' for c in $CONTAINERS; do',
|
||||
" [ -n \"\$c\" ] && [ \"\$c\" != \"coolify-proxy\" ] && docker network connect {$network} \"\$c\" 2>/dev/null || true",
|
||||
' done',
|
||||
' fi',
|
||||
'fi',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures all required networks exist before docker compose up.
|
||||
* This must be called BEFORE docker compose up since the compose file declares networks as external.
|
||||
*
|
||||
* Also detects and fixes networks with IPv6 CIDR gateway notation that causes ParseAddr errors.
|
||||
*
|
||||
* @param Server $server The server to ensure networks on
|
||||
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
|
||||
*/
|
||||
|
|
@ -174,23 +140,17 @@ function ensureProxyNetworksExist(Server $server)
|
|||
|
||||
if ($server->isSwarm()) {
|
||||
$commands = $networks->map(function ($network) {
|
||||
return array_merge(
|
||||
fixNetworkIpv6CidrGateway($network),
|
||||
[
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
|
||||
]
|
||||
);
|
||||
return [
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
|
||||
];
|
||||
});
|
||||
} else {
|
||||
$commands = $networks->map(function ($network) {
|
||||
return array_merge(
|
||||
fixNetworkIpv6CidrGateway($network),
|
||||
[
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
|
||||
]
|
||||
);
|
||||
return [
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1416,24 +1416,48 @@ function checkIPAgainstAllowlist($ip, $allowlist)
|
|||
}
|
||||
|
||||
$mask = (int) $mask;
|
||||
$isIpv6Subnet = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$maxMask = $isIpv6Subnet ? 128 : 32;
|
||||
|
||||
// Validate mask
|
||||
if ($mask < 0 || $mask > 32) {
|
||||
// Validate mask for address family
|
||||
if ($mask < 0 || $mask > $maxMask) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate network addresses
|
||||
$ip_long = ip2long($ip);
|
||||
$subnet_long = ip2long($subnet);
|
||||
if ($isIpv6Subnet) {
|
||||
// IPv6 CIDR matching using binary string comparison
|
||||
$ipBin = inet_pton($ip);
|
||||
$subnetBin = inet_pton($subnet);
|
||||
|
||||
if ($ip_long === false || $subnet_long === false) {
|
||||
continue;
|
||||
}
|
||||
if ($ipBin === false || $subnetBin === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mask_long = ~((1 << (32 - $mask)) - 1);
|
||||
// Build a 128-bit mask from $mask prefix bits
|
||||
$maskBin = str_repeat("\xff", (int) ($mask / 8));
|
||||
$remainder = $mask % 8;
|
||||
if ($remainder > 0) {
|
||||
$maskBin .= chr(0xFF & (0xFF << (8 - $remainder)));
|
||||
}
|
||||
$maskBin = str_pad($maskBin, 16, "\x00");
|
||||
|
||||
if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
|
||||
return true;
|
||||
if (($ipBin & $maskBin) === ($subnetBin & $maskBin)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// IPv4 CIDR matching
|
||||
$ip_long = ip2long($ip);
|
||||
$subnet_long = ip2long($subnet);
|
||||
|
||||
if ($ip_long === false || $subnet_long === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mask_long = ~((1 << (32 - $mask)) - 1);
|
||||
|
||||
if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Special case: 0.0.0.0 means allow all
|
||||
|
|
@ -1451,6 +1475,67 @@ function checkIPAgainstAllowlist($ip, $allowlist)
|
|||
return false;
|
||||
}
|
||||
|
||||
function deduplicateAllowlist(array $entries): array
|
||||
{
|
||||
if (count($entries) <= 1) {
|
||||
return array_values($entries);
|
||||
}
|
||||
|
||||
// Normalize each entry into [original, ip, mask]
|
||||
$parsed = [];
|
||||
foreach ($entries as $entry) {
|
||||
$entry = trim($entry);
|
||||
if (empty($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($entry === '0.0.0.0') {
|
||||
// Special case: bare 0.0.0.0 means "allow all" — treat as /0
|
||||
$parsed[] = ['original' => $entry, 'ip' => '0.0.0.0', 'mask' => 0];
|
||||
} elseif (str_contains($entry, '/')) {
|
||||
[$ip, $mask] = explode('/', $entry);
|
||||
$parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => (int) $mask];
|
||||
} else {
|
||||
$ip = $entry;
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => $isIpv6 ? 128 : 32];
|
||||
}
|
||||
}
|
||||
|
||||
$count = count($parsed);
|
||||
$redundant = array_fill(0, $count, false);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if ($redundant[$i]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ($j = 0; $j < $count; $j++) {
|
||||
if ($i === $j || $redundant[$j]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entry $j is redundant if its mask is narrower/equal (>=) than $i's mask
|
||||
// AND $j's network IP falls within $i's CIDR range
|
||||
if ($parsed[$j]['mask'] >= $parsed[$i]['mask']) {
|
||||
$cidr = $parsed[$i]['ip'].'/'.$parsed[$i]['mask'];
|
||||
if (checkIPAgainstAllowlist($parsed[$j]['ip'], [$cidr])) {
|
||||
$redundant[$j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (! $redundant[$i]) {
|
||||
$result[] = $parsed[$i]['original'];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function get_public_ips()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,13 @@ Route::group([
|
|||
Route::patch('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
|
||||
Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']);
|
||||
Route::get('/applications/{uuid}/storages/{storage_id}', [ApplicationsController::class, 'storage_by_id'])->middleware(['api.ability:read']);
|
||||
Route::patch('/applications/{uuid}/storages/{storage_id}', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/storages/{storage_id}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ test('IP allowlist handles empty and invalid entries', function () {
|
|||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask
|
||||
});
|
||||
|
||||
test('IP allowlist with various subnet sizes', function () {
|
||||
test('IP allowlist with various IPv4 subnet sizes', function () {
|
||||
// /32 - single host
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse();
|
||||
|
|
@ -96,16 +96,98 @@ test('IP allowlist with various subnet sizes', function () {
|
|||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse();
|
||||
|
||||
// /16 - class B
|
||||
// /25 - half a /24
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse();
|
||||
|
||||
// /16
|
||||
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse();
|
||||
|
||||
// /12
|
||||
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse();
|
||||
|
||||
// /8
|
||||
expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse();
|
||||
|
||||
// /0 - all addresses
|
||||
expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('IP allowlist with various IPv6 subnet sizes', function () {
|
||||
// /128 - single host
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
|
||||
|
||||
// /127 - point-to-point link
|
||||
expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse();
|
||||
|
||||
// /64 - standard subnet
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse();
|
||||
|
||||
// /48 - site prefix
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
|
||||
|
||||
// /32 - ISP allocation
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse();
|
||||
|
||||
// /16
|
||||
expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with bare IPv6 addresses', function () {
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with IPv6 CIDR notation', function () {
|
||||
// /64 prefix — issue #8729 exact case
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse();
|
||||
|
||||
// /128 — single host
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
|
||||
|
||||
// /48 prefix
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with mixed IPv4 and IPv6', function () {
|
||||
$allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64'];
|
||||
|
||||
expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist handles invalid IPv6 masks', function () {
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask
|
||||
});
|
||||
|
||||
test('IP allowlist comma-separated string input', function () {
|
||||
// Test with comma-separated string (as it would come from the settings)
|
||||
$allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16';
|
||||
|
|
@ -134,14 +216,21 @@ test('ValidIpOrCidr validation rule', function () {
|
|||
// Valid cases - should pass
|
||||
expect($validate(''))->toBeTrue(); // Empty is allowed
|
||||
expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed
|
||||
expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP
|
||||
expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR
|
||||
expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR
|
||||
expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4
|
||||
expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR
|
||||
expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR
|
||||
expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs
|
||||
expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs
|
||||
expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet
|
||||
expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet
|
||||
expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces
|
||||
// IPv6 valid cases — issue #8729
|
||||
expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6
|
||||
expect($validate('::1'))->toBeTrue(); // Loopback IPv6
|
||||
expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR
|
||||
expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR
|
||||
expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR
|
||||
expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR
|
||||
|
||||
// Invalid cases - should fail
|
||||
expect($validate('1'))->toBeFalse(); // Single digit
|
||||
|
|
@ -155,6 +244,7 @@ test('ValidIpOrCidr validation rule', function () {
|
|||
expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format
|
||||
expect($validate('192.168'))->toBeFalse(); // Incomplete IP
|
||||
expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets
|
||||
expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128
|
||||
});
|
||||
|
||||
test('ValidIpOrCidr validation rule error messages', function () {
|
||||
|
|
@ -181,3 +271,111 @@ test('ValidIpOrCidr validation rule error messages', function () {
|
|||
expect($error)->toContain('10.0.0.256');
|
||||
expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () {
|
||||
// /24
|
||||
expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /16
|
||||
expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']);
|
||||
// /8
|
||||
expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /32 — same host, first entry wins (both equivalent)
|
||||
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']);
|
||||
// /31 — point-to-point
|
||||
expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']);
|
||||
// IP outside subnet — both preserved
|
||||
expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () {
|
||||
// /32 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /25 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /24 inside /16
|
||||
expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']);
|
||||
// /16 inside /12
|
||||
expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']);
|
||||
// /16 inside /8
|
||||
expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /24 inside /8
|
||||
expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /12 inside /8
|
||||
expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']);
|
||||
// /31 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// Non-overlapping CIDRs — both preserved
|
||||
expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']);
|
||||
expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () {
|
||||
// /64 — issue #8729 exact scenario
|
||||
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64']))
|
||||
->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']);
|
||||
// /48
|
||||
expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
|
||||
// /128 — same host, first entry wins (both equivalent)
|
||||
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']);
|
||||
// IP outside prefix — both preserved
|
||||
expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48']))
|
||||
->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () {
|
||||
// /128 inside /64
|
||||
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']);
|
||||
// /127 inside /64
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']);
|
||||
// /64 inside /48
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
|
||||
// /48 inside /32
|
||||
expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
// /32 inside /16
|
||||
expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']);
|
||||
// /64 inside /32
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
// Non-overlapping IPv6 — both preserved
|
||||
expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']);
|
||||
expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () {
|
||||
$result = deduplicateAllowlist([
|
||||
'192.168.1.5', // covered by 192.168.0.0/16
|
||||
'192.168.0.0/16',
|
||||
'2a01:e0a:21d:8230::1', // covered by ::/64
|
||||
'2a01:e0a:21d:8230::/64',
|
||||
'10.0.0.1', // not covered by anything
|
||||
'::1', // not covered by anything
|
||||
]);
|
||||
expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist preserves non-overlapping entries', function () {
|
||||
$result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
|
||||
expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist handles exact duplicates', function () {
|
||||
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']);
|
||||
expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist handles single entry and empty array', function () {
|
||||
expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']);
|
||||
expect(deduplicateAllowlist([]))->toBe([]);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () {
|
||||
$result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']);
|
||||
expect($result)->toBe(['0.0.0.0']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () {
|
||||
// IPv4: three levels of nesting
|
||||
expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// IPv6: three levels of nesting
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
});
|
||||
|
|
|
|||
83
tests/Feature/ServerLimitCheckJobTest.php
Normal file
83
tests/Feature/ServerLimitCheckJobTest.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$this->team = Team::factory()->create(['custom_server_limit' => 5]);
|
||||
});
|
||||
|
||||
function createServerForTeam(Team $team, bool $forceDisabled = false): Server
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
if ($forceDisabled) {
|
||||
$server->settings()->update(['force_disabled' => true]);
|
||||
}
|
||||
|
||||
return $server->fresh(['settings']);
|
||||
}
|
||||
|
||||
it('re-enables force-disabled servers when under the limit', function () {
|
||||
createServerForTeam($this->team);
|
||||
$server2 = createServerForTeam($this->team, forceDisabled: true);
|
||||
$server3 = createServerForTeam($this->team, forceDisabled: true);
|
||||
|
||||
expect($server2->settings->force_disabled)->toBeTruthy();
|
||||
expect($server3->settings->force_disabled)->toBeTruthy();
|
||||
|
||||
// 3 servers, limit 5 → all should be re-enabled
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('re-enables force-disabled servers when exactly at the limit', function () {
|
||||
$this->team->update(['custom_server_limit' => 3]);
|
||||
|
||||
createServerForTeam($this->team);
|
||||
createServerForTeam($this->team);
|
||||
$server3 = createServerForTeam($this->team, forceDisabled: true);
|
||||
|
||||
// 3 servers, limit 3 → disabled one should be re-enabled
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('disables newest servers when over the limit', function () {
|
||||
$this->team->update(['custom_server_limit' => 2]);
|
||||
|
||||
$oldest = createServerForTeam($this->team);
|
||||
sleep(1);
|
||||
$middle = createServerForTeam($this->team);
|
||||
sleep(1);
|
||||
$newest = createServerForTeam($this->team);
|
||||
|
||||
// 3 servers, limit 2 → newest 1 should be disabled
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($oldest->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($middle->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($newest->fresh()->settings->force_disabled)->toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not change servers when under limit and none are force-disabled', function () {
|
||||
$server1 = createServerForTeam($this->team);
|
||||
$server2 = createServerForTeam($this->team);
|
||||
|
||||
// 2 servers, limit 5 → nothing to do
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
});
|
||||
Loading…
Reference in a new issue