Merge remote-tracking branch 'origin/next' into feat/railpack

This commit is contained in:
Andras Bacsai 2026-04-29 08:56:23 +02:00
commit 9717d9ff5a
47 changed files with 2015 additions and 83 deletions

View file

@ -4,8 +4,10 @@
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/**
* A list of exception types with their corresponding custom log levels.
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
* @var array<class-string<Throwable>, LogLevel::*>
*/
protected $levels = [
//
@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
* @var array<int, class-string<Throwable>>
*/
protected $dontReport = [
ProcessException::class,
@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
if ($request->is('api/*')) {
auditLog('api.auth.unauthenticated', [
'reason' => $exception->getMessage(),
'guards' => $exception->guards(),
], 'warning');
}
return response()->json(['message' => $exception->getMessage()], 401);
}
@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception)
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
if ($request->is('api/*')) {
auditLog('api.auth.policy_denied', [
'reason' => $e->getMessage(),
'route' => $request->route()?->getName() ?? $request->path(),
], 'warning');
}
// Get the custom message from the policy if available
$message = $e->getMessage();

View file

@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$connectionTimeout = config('constants.ssh.connection_timeout');
$connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
} else {
@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command);
$delimiter = base64_encode($delimiter);
@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void
}
}
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
return is_numeric($timeout) && (int) $timeout > 0
? (int) $timeout
: (int) config('constants.ssh.connection_timeout');
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = "-i {$sshKeyLocation} "

View file

@ -1309,6 +1309,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1539,6 +1548,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1739,6 +1757,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1846,6 +1873,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1956,6 +1992,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -2039,6 +2084,14 @@ private function create_application(Request $request, $type)
StartService::dispatch($service);
}
auditLog('api.application.created', [
'team_id' => $teamId,
'service_uuid' => data_get($service, 'uuid'),
'service_name' => data_get($service, 'name'),
'application_type' => $type,
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($service, 'uuid'),
'domains' => data_get($service, 'domains'),
@ -2297,6 +2350,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
auditLog('api.application.deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
return response()->json([
'message' => 'Application deletion request queued.',
]);
@ -2796,6 +2855,13 @@ public function update_by_uuid(Request $request)
}
$application->save();
auditLog('api.application.updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@ -3048,6 +3114,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.application.env_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@ -3081,6 +3155,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.application.env_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@ -3307,6 +3389,12 @@ public function create_bulk_envs(Request $request)
$returnedEnvs->push($this->removeSensitiveData($env));
}
auditLog('api.application.env_bulk_upserted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_count' => $returnedEnvs->count(),
]);
return response()->json($returnedEnvs)->setStatusCode(201);
}
@ -3446,6 +3534,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
auditLog('api.application.env_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@ -3471,6 +3567,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
auditLog('api.application.env_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@ -3562,8 +3666,17 @@ public function delete_env_by_uuid(Request $request)
'message' => 'Environment variable not found.',
], 404);
}
$envKey = $found_env->key;
$envUuid = $found_env->uuid;
$found_env->forceDelete();
auditLog('api.application.env_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json([
'message' => 'Environment variable deleted.',
]);
@ -3675,6 +3788,15 @@ public function action_deploy(Request $request)
);
}
auditLog('api.application.deployed', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'force_rebuild' => $force,
'instant_deploy' => $instant_deploy,
]);
return response()->json(
[
'message' => 'Deployment request queued.',
@ -3763,6 +3885,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
auditLog('api.application.stopped', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'docker_cleanup' => $dockerCleanup,
]);
return response()->json(
[
'message' => 'Application stopping request queued.',
@ -3853,6 +3982,13 @@ public function action_restart(Request $request)
], 200);
}
auditLog('api.application.restarted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
]);
return response()->json(
[
'message' => 'Restart request queued.',
@ -4221,6 +4357,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
auditLog('api.application.storage_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
}
@ -4399,6 +4544,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
auditLog('api.application.storage_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
}
@ -4472,8 +4626,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete();
auditLog('api.application.storage_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']);
}
@ -4543,6 +4707,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$preview->delete();
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
auditLog('api.application.preview_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'pull_request_id' => $pullRequestId,
]);
return response()->json(['message' => 'Preview deletion request queued.']);
}
}

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -244,7 +245,7 @@ public function store(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -286,6 +287,13 @@ public function store(Request $request)
'name' => $body['name'],
]);
auditLog('api.cloud_token.created', [
'team_id' => $teamId,
'cloud_token_uuid' => $cloudProviderToken->uuid,
'cloud_token_name' => $cloudProviderToken->name,
'provider' => $cloudProviderToken->provider,
]);
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
@ -355,7 +363,7 @@ public function update(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -389,6 +397,14 @@ public function update(Request $request)
$token->update(array_intersect_key($body, array_flip($allowedFields)));
auditLog('api.cloud_token.updated', [
'team_id' => $teamId,
'cloud_token_uuid' => $token->uuid,
'cloud_token_name' => $token->name,
'provider' => $token->provider,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
]);
return response()->json([
'uuid' => $token->uuid,
]);
@ -464,8 +480,18 @@ public function destroy(Request $request)
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
$tokenUuid = $token->uuid;
$tokenName = $token->name;
$tokenProvider = $token->provider;
$token->delete();
auditLog('api.cloud_token.deleted', [
'team_id' => $teamId,
'cloud_token_uuid' => $tokenUuid,
'cloud_token_name' => $tokenName,
'provider' => $tokenProvider,
]);
return response()->json(['message' => 'Cloud provider token deleted.']);
}

View file

@ -596,6 +596,14 @@ public function update_by_uuid(Request $request)
StopDatabaseProxy::dispatch($database);
}
auditLog('api.database.updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'message' => 'Database updated.',
]);
@ -826,6 +834,15 @@ public function create_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
auditLog('api.database.backup_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $backupConfig->uuid,
'frequency' => $backupConfig->frequency,
'save_s3' => (bool) $backupConfig->save_s3,
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@ -1045,6 +1062,14 @@ public function update_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
auditLog('api.database.backup_updated', [
'team_id' => $teamId,
'backup_uuid' => $backupConfig->uuid,
'database_id' => $backupConfig->database_id,
'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([
'message' => 'Database backup configuration updated',
]);
@ -1779,6 +1804,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
@ -1838,6 +1873,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
@ -1897,6 +1942,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
@ -1953,6 +2008,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
@ -2039,6 +2104,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
@ -2075,6 +2150,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
@ -2133,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
auditLog('api.database.deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json([
'message' => 'Database deletion request queued.',
]);
@ -2329,6 +2431,14 @@ public function delete_backup_by_uuid(Request $request)
$backup->delete();
DB::commit();
auditLog('api.database.backup_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'delete_s3' => $deleteS3,
'executions_deleted' => $executions->count(),
]);
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
@ -2451,6 +2561,14 @@ public function delete_execution_by_uuid(Request $request)
$execution->delete();
auditLog('api.database.backup_execution_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'execution_uuid' => $request->execution_uuid,
'delete_s3' => $deleteS3,
]);
return response()->json([
'message' => 'Backup execution deleted.',
]);
@ -2633,6 +2751,13 @@ public function action_deploy(Request $request)
}
StartDatabase::dispatch($database);
auditLog('api.database.started', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json(
[
'message' => 'Database starting request queued.',
@ -2724,6 +2849,14 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
auditLog('api.database.stopped', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'docker_cleanup' => $dockerCleanup,
]);
return response()->json(
[
'message' => 'Database stopping request queued.',
@ -2801,6 +2934,13 @@ public function action_restart(Request $request)
RestartDatabase::dispatch($database);
auditLog('api.database.restarted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json(
[
'message' => 'Database restarting request queued.',
@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.database.env_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
auditLog('api.database.env_bulk_upserted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_count' => $updatedEnvs->count(),
]);
return response()->json($updatedEnvs)->setStatusCode(201);
}
@ -3266,6 +3419,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
auditLog('api.database.env_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$envKey = $env->key;
$envUuid = $env->uuid;
$env->forceDelete();
auditLog('api.database.env_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json(['message' => 'Environment variable deleted.']);
}
@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
auditLog('api.database.storage_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
}
@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
auditLog('api.database.storage_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
}
@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete();
auditLog('api.database.storage_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -281,6 +281,14 @@ public function cancel_deployment(Request $request)
}
}
auditLog('api.deployment.cancelled', [
'team_id' => $teamId,
'deployment_uuid' => $deployment->deployment_uuid,
'application_id' => $application?->id,
'application_uuid' => $application?->uuid,
'server_id' => $deployment->server_id,
]);
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
auditLog('api.deployment.triggered', [
'resource_type' => 'application',
'application_uuid' => $resource->uuid,
'application_name' => $resource->name,
'deployment_uuid' => $deployment_uuid?->toString(),
'force_rebuild' => $force,
'pull_request_id' => $pr,
]);
}
break;
case Service::class:
@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
}
StartService::run($resource);
$message = "Service {$resource->name} started. It could take a while, be patient.";
auditLog('api.service.deployed', [
'service_uuid' => $resource->uuid,
'service_name' => $resource->name,
]);
break;
default:
// Database resource - check authorization
@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$resource->save();
$message = "Database {$resource->name} started.";
auditLog('api.database.started', [
'database_uuid' => $resource->uuid,
'database_name' => $resource->name,
'database_type' => $resource->getMorphClass(),
]);
break;
}

View file

@ -271,6 +271,12 @@ public function create_github_app(Request $request)
$githubApp = GithubApp::create($payload);
auditLog('api.github_app.created', [
'team_id' => $teamId,
'github_app_uuid' => $githubApp->uuid,
'github_app_name' => $githubApp->name,
]);
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id)
// Update the GitHub app
$githubApp->update($payload);
auditLog('api.github_app.updated', [
'team_id' => $teamId,
'github_app_uuid' => $githubApp->uuid,
'github_app_name' => $githubApp->name,
'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
]);
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
@ -734,8 +747,16 @@ public function delete_github_app($github_app_id)
], 409);
}
$deletedUuid = $githubApp->uuid;
$deletedName = $githubApp->name;
$githubApp->delete();
auditLog('api.github_app.deleted', [
'team_id' => $teamId,
'github_app_uuid' => $deletedUuid,
'github_app_name' => $deletedName,
]);
return response()->json([
'message' => 'GitHub app deleted successfully',
]);

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
@ -12,6 +13,7 @@
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@ -550,7 +552,7 @@ public function createServer(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -717,9 +719,17 @@ public function createServer(Request $request)
// Validate server if requested
if ($request->instant_validate) {
\App\Actions\Server\ValidateServer::dispatch($server);
ValidateServer::dispatch($server);
}
auditLog('api.hetzner_server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'hetzner_server_id' => $hetznerServer['id'],
'ip' => $ipAddress,
]);
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],

View file

@ -85,11 +85,15 @@ public function enable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => true]);
auditLog('api.instance.enabled', ['team_id' => $teamId]);
return response()->json(['message' => 'API enabled.'], 200);
}
@ -137,11 +141,15 @@ public function disable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => false]);
auditLog('api.instance.disabled', ['team_id' => $teamId]);
return response()->json(['message' => 'API disabled.'], 200);
}

View file

@ -264,6 +264,12 @@ public function create_project(Request $request)
'team_id' => $teamId,
]);
auditLog('api.project.created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
]);
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@ -382,6 +388,13 @@ public function update_project(Request $request)
$project->update($request->only($allowedFields));
auditLog('api.project.updated', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@ -460,8 +473,16 @@ public function delete_project(Request $request)
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
$projectUuid = $project->uuid;
$projectName = $project->name;
$project->delete();
auditLog('api.project.deleted', [
'team_id' => $teamId,
'project_uuid' => $projectUuid,
'project_name' => $projectName,
]);
return response()->json(['message' => 'Project deleted.']);
}
@ -641,6 +662,13 @@ public function create_environment(Request $request)
'name' => $request->name,
]);
auditLog('api.project.environment_created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'environment_name' => $environment->name,
]);
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
@ -723,8 +751,17 @@ public function delete_environment(Request $request)
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
$envUuid = $environment->uuid;
$envName = $environment->name;
$environment->delete();
auditLog('api.project.environment_deleted', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'environment_uuid' => $envUuid,
'environment_name' => $envName,
]);
return response()->json(['message' => 'Environment deleted.']);
}
}

View file

@ -6,6 +6,7 @@
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json
return response()->json($tasks);
}
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function createTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I
$task->save();
auditLog('api.scheduled_task.created', [
'team_id' => $teamId,
'task_uuid' => $task->uuid,
'task_name' => $task->name,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
]);
return response()->json($this->removeSensitiveData($task), 201);
}
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function updateTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I
$task->update($request->only($allowedFields));
auditLog('api.scheduled_task.updated', [
'team_id' => getTeamIdFromToken(),
'task_uuid' => $task->uuid,
'task_name' => $task->name,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json($this->removeSensitiveData($task), 200);
}
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
if (! $deleted) {
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$taskUuid = $task->uuid;
$taskName = $task->name;
$task->delete();
auditLog('api.scheduled_task.deleted', [
'team_id' => getTeamIdFromToken(),
'task_uuid' => $taskUuid,
'task_name' => $taskName,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
]);
return response()->json(['message' => 'Scheduled task deleted.']);
}
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource):
),
]
)]
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina
),
]
)]
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function executions_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht
),
]
)]
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H
),
]
)]
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function executions_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {

View file

@ -232,6 +232,13 @@ public function create_key(Request $request)
'private_key' => $request->private_key,
]);
auditLog('api.private_key.created', [
'team_id' => $teamId,
'private_key_uuid' => $key->uuid,
'private_key_name' => $key->name,
'fingerprint' => $fingerPrint,
]);
return response()->json(serializeApiResponse([
'uuid' => $key->uuid,
]))->setStatusCode(201);
@ -333,6 +340,13 @@ public function update_key(Request $request)
}
$foundKey->update($request->only($allowedFields));
auditLog('api.private_key.updated', [
'team_id' => $teamId,
'private_key_uuid' => $foundKey->uuid,
'private_key_name' => $foundKey->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
]))->setStatusCode(201);
@ -415,8 +429,16 @@ public function delete_key(Request $request)
], 422);
}
$keyUuid = $key->uuid;
$keyName = $key->name;
$key->forceDelete();
auditLog('api.private_key.deleted', [
'team_id' => $teamId,
'private_key_uuid' => $keyUuid,
'private_key_name' => $keyName,
]);
return response()->json([
'message' => 'Private Key deleted.',
]);

View file

@ -13,6 +13,7 @@
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Stringable;
@ -477,7 +478,7 @@ public function create_server(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@ -564,6 +565,14 @@ public function create_server(Request $request)
ValidateServer::dispatch($server);
}
auditLog('api.server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'ip' => $server->ip,
'is_build_server' => (bool) $request->is_build_server,
]);
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@ -603,6 +612,7 @@ public function create_server(Request $request)
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
],
),
),
@ -639,7 +649,7 @@ public function create_server(Request $request)
)]
public function update_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -647,7 +657,7 @@ public function update_server(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@ -665,6 +675,7 @@ public function update_server(Request $request)
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
'connection_timeout' => 'integer|min:1|max:300',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -709,7 +720,7 @@ public function update_server(Request $request)
], 422);
}
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
@ -718,6 +729,13 @@ public function update_server(Request $request)
ValidateServer::dispatch($server);
}
auditLog('api.server.updated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@ -807,6 +825,9 @@ public function delete_server(Request $request)
}
}
$deletedUuid = $server->uuid;
$deletedName = $server->name;
$deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@ -816,6 +837,14 @@ public function delete_server(Request $request)
$server->team_id
);
auditLog('api.server.deleted', [
'team_id' => $teamId,
'server_uuid' => $deletedUuid,
'server_name' => $deletedName,
'ip' => $deletedIp,
'force' => $force,
]);
return response()->json(['message' => 'Server deleted.']);
}
@ -881,6 +910,12 @@ public function validate_server(Request $request)
}
ValidateServer::dispatch($server);
auditLog('api.server.validated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
]);
return response()->json(['message' => 'Validation started.'], 201);
}
}

View file

@ -486,6 +486,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
auditLog('api.service.created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'service_type' => $oneClickServiceName ?? null,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -650,6 +658,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
auditLog('api.service.created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'service_type' => 'docker_compose',
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
auditLog('api.service.deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
]);
return response()->json([
'message' => 'Service deletion request queued.',
]);
@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request)
StartService::dispatch($service);
}
auditLog('api.service.updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.service.env_updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveData($env));
}
auditLog('api.service.env_bulk_upserted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_count' => $updatedEnvs->count(),
]);
return response()->json($updatedEnvs)->setStatusCode(201);
}
@ -1506,6 +1548,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
auditLog('api.service.env_created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$envKey = $env->key;
$envUuid = $env->uuid;
$env->forceDelete();
auditLog('api.service.env_deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json(['message' => 'Environment variable deleted.']);
}
@ -1668,6 +1726,12 @@ public function action_deploy(Request $request)
}
StartService::dispatch($service);
auditLog('api.service.deployed', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
]);
return response()->json(
[
'message' => 'Service starting request queued.',
@ -1759,6 +1823,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
auditLog('api.service.stopped', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'docker_cleanup' => $dockerCleanup,
]);
return response()->json(
[
'message' => 'Service stopping request queued.',
@ -1846,6 +1917,13 @@ public function action_restart(Request $request)
$pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
auditLog('api.service.restarted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'pull_latest' => $pullLatest,
]);
return response()->json(
[
'message' => 'Service restarting request queued.',
@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
auditLog('api.service.storage_created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
}
@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
auditLog('api.service.storage_updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
}
@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete();
auditLog('api.service.storage_deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -58,6 +58,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
if (empty($webhook_secret)) {
auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -70,6 +76,12 @@ public function manual(Request $request)
$parts = explode('=', $x_bitbucket_token, 2);
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
auditLogWebhookFailure('bitbucket', 'malformed_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -81,6 +93,12 @@ public function manual(Request $request)
$hash = $parts[1];
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
auditLogWebhookFailure('bitbucket', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -118,6 +136,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'bitbucket',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => $commit,
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',

View file

@ -68,6 +68,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -78,6 +84,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
auditLogWebhookFailure('gitea', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -117,6 +129,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'gitea',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',

View file

@ -82,6 +82,12 @@ public function manual(Request $request)
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -92,6 +98,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
auditLogWebhookFailure('github', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -131,6 +143,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@ -224,6 +245,13 @@ public function normal(Request $request)
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
}
@ -311,6 +339,17 @@ public function normal(Request $request)
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'app',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'github_app_id' => $github_app->id,
]);
}
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],

View file

@ -32,6 +32,9 @@ public function manual(Request $request)
}
if (empty($x_gitlab_token)) {
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@ -101,6 +104,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (empty($webhook_secret)) {
auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -110,6 +119,12 @@ public function manual(Request $request)
continue;
}
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
auditLogWebhookFailure('gitlab', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -150,6 +165,15 @@ public function manual(Request $request)
'application_name' => $application->name,
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'gitlab',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',

View file

@ -6,6 +6,8 @@
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class Stripe extends Controller
{
@ -14,7 +16,7 @@ public function events(Request $request)
try {
$webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature');
$event = \Stripe\Webhook::constructEvent(
$event = Webhook::constructEvent(
$request->getContent(),
$signature,
$webhookSecret
@ -22,6 +24,12 @@ public function events(Request $request)
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
} catch (SignatureVerificationException $e) {
auditLogWebhookFailure('stripe', 'invalid_signature', [
'error' => $e->getMessage(),
]);
return response($e->getMessage(), 400);
} catch (Exception $e) {
return response($e->getMessage(), 400);
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use Illuminate\Auth\AuthenticationException;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities)
}
return parent::handle($request, $next, ...$abilities);
} catch (\Illuminate\Auth\AuthenticationException $e) {
} catch (AuthenticationException $e) {
auditLog('api.auth.unauthenticated', [
'reason' => $e->getMessage(),
'required_abilities' => $abilities,
], 'warning');
return response()->json([
'message' => 'Unauthenticated.',
], 401);
} catch (\Exception $e) {
auditLog('api.auth.ability_denied', [
'required_abilities' => $abilities,
'token_id' => $request->user()?->currentAccessToken()?->id,
'reason' => $e->getMessage(),
], 'warning');
return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403);

View file

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
@ -43,6 +44,9 @@ private function disableSshMux(): void
public function handle()
{
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
@ -84,6 +88,8 @@ public function handle()
'server_ip' => $this->server->ip,
]);
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
return;
}
@ -99,6 +105,8 @@ public function handle()
$this->server->update(['unreachable_count' => 0]);
}
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@ -111,6 +119,8 @@ public function handle()
]);
$this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
return;
}
}
@ -118,17 +128,41 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof TimeoutExceededException) {
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
/**
* Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
* or when a previously-notified server recovers. Skips noise from single transient flaps.
*/
private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
{
if ($isReachable) {
if (! $wasReachable || $wasNotified) {
ServerReachabilityChanged::dispatch($this->server);
}
return;
}
if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
ServerReachabilityChanged::dispatch($this->server);
}
}
private function checkHetznerStatus(): void
{
$status = null;

View file

@ -63,13 +63,16 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
if ($this->fileStorage->is_too_large) {
return;
}
$this->validate();
// Sync to model
@ -172,6 +175,12 @@ public function submit()
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
return;
}
$original = $this->fileStorage->getOriginal();
try {
$this->validate();
@ -197,6 +206,11 @@ public function submit()
public function instantSave(): void
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
return;
}
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}

View file

@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
$this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
}
});
$this->resource->load('persistentStorages.resource');
}

View file

@ -32,6 +32,8 @@ class Show extends Component
public string $port;
public int $connectionTimeout;
public ?string $validationLogs = null;
public ?string $wildcardDomain = null;
@ -110,6 +112,7 @@ protected function rules(): array
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'port' => 'required|integer|between:1,65535',
'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
'wildcardDomain' => 'nullable|url',
'isReachable' => 'required',
@ -138,6 +141,10 @@ protected function messages(): array
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
'port.required' => 'The Port field is required.',
'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.',
'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.',
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
'sentinelToken.required' => 'The Sentinel Token field is required.',
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.',
@ -210,6 +217,7 @@ public function syncData(bool $toModel = false)
$this->server->validation_logs = $this->validationLogs;
$this->server->save();
$this->server->settings->connection_timeout = $this->connectionTimeout;
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
@ -237,6 +245,7 @@ public function syncData(bool $toModel = false)
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
$this->connectionTimeout = $this->server->settings->connection_timeout;
$this->wildcardDomain = $this->server->settings->wildcard_domain;
$this->isReachable = $this->server->settings->is_reachable;
@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false)
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
@ -471,7 +480,7 @@ public function startHetznerServer()
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';

View file

@ -10,6 +10,12 @@
class LocalFileVolume extends BaseModel
{
public const MAX_CONTENT_SIZE = 5_242_880;
public const BINARY_PLACEHOLDER = '[binary file]';
public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled',
];
public $appends = ['is_binary'];
public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute
{
return Attribute::make(
get: function () {
return $this->content === '[binary file]';
}
get: fn () => $this->content === self::BINARY_PLACEHOLDER
);
}
protected function isTooLarge(): Attribute
{
return Attribute::make(
get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
);
}
@ -81,10 +92,17 @@ public function loadStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
$this->is_directory = false;
$this->save();
return;
}
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]';
$content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$this->is_directory = false;
@ -92,6 +110,18 @@ public function loadStorageOnServer()
}
}
protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
{
$sizeOutput = instant_remote_process(
["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
$server,
false,
);
$size = (int) trim((string) $sizeOutput);
return $size > self::MAX_CONTENT_SIZE;
}
public function deleteStorageOnServer()
{
$this->load(['service']);
@ -173,9 +203,12 @@ public function saveStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
} else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
}
$this->is_directory = false;
$this->content = $content;
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');

View file

@ -1236,10 +1236,8 @@ public function isReachableChanged()
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
if ($isReachable === true) {
$this->unreachable_count = 0;
$this->save();
if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
@ -1247,28 +1245,8 @@ public function isReachableChanged()
return;
}
$this->increment('unreachable_count');
if ($this->unreachable_count === 1) {
$this->settings->is_reachable = true;
$this->settings->save();
return;
}
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
$failedChecks = 0;
for ($i = 0; $i < 3; $i++) {
$status = $this->serverStatus();
sleep(5);
if (! $status) {
$failedChecks++;
}
}
if ($failedChecks === 3 && ! $unreachableNotificationSent) {
$this->sendUnreachableNotification();
}
$this->sendUnreachableNotification();
}
}

View file

@ -49,6 +49,7 @@
'updated_at' => ['type' => 'string'],
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
]
)]
class ServerSetting extends Model
@ -97,6 +98,7 @@ class ServerSetting extends Model
'is_terminal_enabled',
'deployment_queue_limit',
'disable_application_image_retention',
'connection_timeout',
];
protected $casts = [
@ -108,6 +110,7 @@ class ServerSetting extends Model
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
];
protected static function booted()

View file

@ -0,0 +1,81 @@
<?php
use Illuminate\Support\Facades\Log;
if (! function_exists('auditLog')) {
/**
* Write a security-relevant audit entry to the dedicated `audit` log channel.
*
* Never include secrets (private keys, passwords, tokens, webhook secrets,
* signature header values, env-var values) in $context.
*
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
* @param array<string, mixed> $context Identifiers + outcome details.
* @param string $level Log level: info | warning | error.
*/
function auditLog(string $event, array $context = [], string $level = 'info'): void
{
try {
$request = app()->bound('request') ? request() : null;
$user = auth()->check() ? auth()->user() : null;
$token = $user?->currentAccessToken();
$base = [
'event' => $event,
'ip' => $request?->ip(),
'ua' => substr((string) $request?->userAgent(), 0, 200),
'user_id' => $user?->id,
'user_email' => $user?->email,
'team_id' => $token ? data_get($token, 'team_id') : null,
'token_id' => $token?->id ?? null,
'token_name' => $token?->name ?? null,
'method' => $request?->method(),
'path' => $request?->path(),
];
$payload = array_merge($base, $context);
Log::channel('audit')->{$level}($event, $payload);
} catch (Throwable $e) {
// Audit logging must never break the request path.
try {
Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
} catch (Throwable) {
}
}
}
}
if (! function_exists('auditLogWebhookFailure')) {
/**
* Record a webhook signature/auth verification failure to the `audit` channel.
*/
function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
{
try {
$request = app()->bound('request') ? request() : null;
$event = "webhook.{$provider}.signature_failed";
$base = [
'event' => $event,
'reason' => $reason,
'ip' => $request?->ip(),
'ua' => substr((string) $request?->userAgent(), 0, 200),
'method' => $request?->method(),
'path' => $request?->path(),
'event_header' => $request?->header('X-GitHub-Event')
?? $request?->header('X-Gitlab-Event')
?? $request?->header('X-Gitea-Event')
?? $request?->header('X-Event-Key'),
];
Log::channel('audit')->warning($event, array_merge($base, $context));
} catch (Throwable $e) {
try {
Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
} catch (Throwable) {
}
}
}
}

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0',
'version' => '4.0.1',
'helper_version' => '1.0.13',
'realtime_version' => '1.0.14',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -132,6 +132,14 @@
'level' => 'warning',
'days' => 14,
],
'audit' => [
'driver' => 'daily',
'path' => storage_path('logs/audit.log'),
'level' => env('LOG_AUDIT_LEVEL', 'info'),
'days' => env('LOG_AUDIT_DAYS', 90),
'replace_placeholders' => true,
],
],
];

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->integer('connection_timeout')->default(10)->after('deployment_queue_limit');
});
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('connection_timeout');
});
}
};

View file

@ -129,7 +129,7 @@ services:
networks:
- coolify
minio:
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
image: ghcr.io/coollabsio/maxio:latest
pull_policy: always
container_name: coolify-minio
command: server /data --console-address ":9001"

View file

@ -10550,6 +10550,10 @@
"server_disk_usage_check_frequency": {
"type": "string",
"description": "Cron expression for disk usage check frequency."
},
"connection_timeout": {
"type": "integer",
"description": "SSH connection timeout in seconds (1-300). Default: 10."
}
},
"type": "object"
@ -13355,6 +13359,10 @@
"delete_unused_networks": {
"type": "boolean",
"description": "The flag to indicate if the unused networks should be deleted."
},
"connection_timeout": {
"type": "integer",
"description": "SSH connection timeout in seconds."
}
},
"type": "object"

View file

@ -6734,6 +6734,9 @@ paths:
server_disk_usage_check_frequency:
type: string
description: 'Cron expression for disk usage check frequency.'
connection_timeout:
type: integer
description: 'SSH connection timeout in seconds (1-300). Default: 10.'
type: object
responses:
'201':
@ -8539,6 +8542,9 @@ components:
delete_unused_networks:
type: boolean
description: 'The flag to indicate if the unused networks should be deleted.'
connection_timeout:
type: integer
description: 'SSH connection timeout in seconds.'
type: object
Service:
description: 'Service model'

View file

@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.0.0"
"version": "4.0.1"
},
"nightly": {
"version": "4.0.0"
@ -10,7 +10,7 @@
"version": "1.0.13"
},
"realtime": {
"version": "1.0.13"
"version": "1.0.14"
},
"sentinel": {
"version": "0.0.21"

View file

@ -1,6 +1,10 @@
<div>
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly)
@if ($fileStorage->is_too_large)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
</div>
@elseif ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
@if ($fileStorage->is_directory)
This directory is mounted as read-only and cannot be modified from the UI.
@ -44,7 +48,7 @@
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@else
@if (!$fileStorage->is_binary)
@if (!$fileStorage->is_binary && !$fileStorage->is_too_large)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'The selected file will be permanently deleted and an empty directory will be created in its place.',
@ -76,8 +80,8 @@
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large)
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
@endif
@else

View file

@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1
label="Port" required :disabled="$isValidating" />
</div>
</div>
<div class="w-full lg:w-64">
<x-forms.input canGate="update" :canResource="$server" type="number"
id="connectionTimeout" label="SSH Connection Timeout (s)"
helper="Seconds to wait for SSH connection before failing. Default: 10."
min="1" max="300" required :disabled="$isValidating" />
</div>
<div class="w-full">
<div class="flex items-center mb-1">
<label for="serverTimezone">Server Timezone</label>

View file

@ -215,6 +215,8 @@
Route::post('/sentinel/push', function () {
$token = request()->header('Authorization');
if (! $token) {
auditLogWebhookFailure('sentinel', 'token_missing');
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
@ -222,26 +224,49 @@
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
auditLogWebhookFailure('sentinel', 'decrypt_failed');
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
if (! $server_uuid) {
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
return response()->json(['message' => 'Invalid token'], 401);
}
$server = Server::where('uuid', $server_uuid)->first();
if (! $server) {
auditLogWebhookFailure('sentinel', 'server_not_found', [
'server_uuid' => $server_uuid,
]);
return response()->json(['message' => 'Server not found'], 404);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
if ($server->isFunctional() === false) {
auditLogWebhookFailure('sentinel', 'server_not_functional', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Server is not functional'], 401);
}
if ($server->settings->sentinel_token !== $naked_token) {
auditLogWebhookFailure('sentinel', 'token_mismatch', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
$data = request()->all();
@ -249,6 +274,11 @@
// \App\Jobs\ServerCheckNewJob::dispatch($server, $data);
PushServerUpdateJob::dispatch($server, $data);
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'ok'], 200);
});
});

View file

@ -0,0 +1,445 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
function makeAuditTeamUser(): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
test()->actingAs($user);
return [$team, $user];
}
function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string
{
$token = $user->createToken('audit-test', $abilities);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
'team_id' => $team->id,
]);
return $token->plainTextToken;
}
function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
return Application::create([
'name' => 'audit-test-app',
'git_repository' => "https://github.com/{$repo}",
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
describe('audit channel helper', function () {
test('auditLog writes structured payload to audit channel', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('test.event', Mockery::on(function ($context) {
return $context['event'] === 'test.event'
&& $context['custom_field'] === 'value'
&& array_key_exists('ip', $context)
&& array_key_exists('user_id', $context);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLog('test.event', ['custom_field' => 'value']);
});
test('auditLog warning level routes correctly', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLog('test.failed', [], 'warning');
});
test('auditLogWebhookFailure logs warning with provider tag', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->once()
->with('webhook.github.signature_failed', Mockery::on(function ($context) {
return $context['reason'] === 'invalid_signature'
&& $context['event'] === 'webhook.github.signature_failed'
&& array_key_exists('ip', $context);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']);
});
test('auditLog never includes raw secret keys in context', function () {
$captured = null;
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) {
$captured = $context;
return true;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLog('test.private_key.created', [
'team_id' => '1',
'private_key_uuid' => 'abc',
'fingerprint' => 'SHA256:xyz',
]);
expect($captured)->toBeArray();
// Helper itself never injects secret-bearing keys.
$disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret'];
foreach (array_keys($captured) as $key) {
expect(in_array(strtolower($key), $disallowed, true))->toBeFalse();
}
});
});
describe('webhook signature failure logging', function () {
test('GitHub manual webhook with bad signature logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.github.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('GitLab manual webhook with bad token logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.gitlab.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
], [
'X-Gitlab-Token' => 'wrong-token',
]);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('Bitbucket manual webhook with malformed signature logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.bitbucket.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => 'test-org/test-repo'],
]);
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha1=anyvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('Gitea manual webhook with bad signature logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.gitea.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
'HTTP_X-Gitea-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
});
describe('API mutation audit logging', function () {
test('private key creation emits api.private_key.created audit event', function () {
[$team, $user] = makeAuditTeamUser();
$token = makeAuditApiToken($user, $team);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->atLeast()
->once()
->with('api.private_key.created', Mockery::on(function ($context) {
return $context['event'] === 'api.private_key.created'
&& ! array_key_exists('private_key', $context);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
// Generate a valid OpenSSH-format private key for the test.
$opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
base64_encode(str_repeat('a', 256)).
"\n-----END OPENSSH PRIVATE KEY-----";
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/security/keys', [
'name' => 'test-key',
'description' => 'audit test',
'private_key' => $opensshKey,
]);
// Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201.
expect($response->status())->toBeIn([201, 422]);
});
test('enable_api denial for non-root team emits warning audit event', function () {
[$team, $user] = makeAuditTeamUser();
$token = makeAuditApiToken($user, $team);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.instance.enable_denied', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/enable');
$response->assertStatus(403);
});
test('project creation emits api.project.created audit event', function () {
[$team, $user] = makeAuditTeamUser();
$token = makeAuditApiToken($user, $team);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->atLeast()
->once()
->with('api.project.created', Mockery::on(function ($context) {
return $context['event'] === 'api.project.created'
&& ! empty($context['project_uuid'])
&& $context['project_name'] === 'audit-project';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'audit-project',
'description' => 'audit',
]);
$response->assertStatus(201);
});
});
describe('threat-detection audit logging (Phase 2)', function () {
test('missing bearer token logs api.auth.unauthenticated', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.auth.unauthenticated', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->getJson('/api/v1/projects');
$response->assertStatus(401);
});
test('expired bearer token logs api.auth.unauthenticated', function () {
[$team, $user] = makeAuditTeamUser();
$token = $user->createToken('expired-audit', ['read'], now()->subDay());
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
'team_id' => $team->id,
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.auth.unauthenticated', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/projects');
$response->assertStatus(401);
});
test('read-only token hitting write endpoint logs api.auth.ability_denied', function () {
[$team, $user] = makeAuditTeamUser();
$readToken = makeAuditApiToken($user, $team, ['read']);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.auth.ability_denied', Mockery::on(function ($ctx) {
return in_array('write', $ctx['required_abilities'] ?? [], true);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'should-fail',
]);
$response->assertStatus(403);
});
test('sentinel push without Authorization logs token_missing', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
return $ctx['reason'] === 'token_missing';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->postJson('/api/v1/sentinel/push', []);
$response->assertStatus(401);
});
test('sentinel push with un-decryptable bearer logs decrypt_failed', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
return $ctx['reason'] === 'decrypt_failed';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer not-a-valid-encrypted-payload',
])->postJson('/api/v1/sentinel/push', []);
$response->assertStatus(401);
});
});

View file

@ -0,0 +1,74 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
$newToken = $this->user->createToken('write-token', ['write']);
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->token = $newToken->plainTextToken;
});
it('PATCH updates connection_timeout via API', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 45,
]);
$response->assertStatus(201);
expect($this->server->settings->fresh()->connection_timeout)->toBe(45);
});
it('PATCH rejects connection_timeout out of range', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 0,
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});
it('PATCH rejects connection_timeout above max', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 999,
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});
it('PATCH rejects non-integer connection_timeout', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 'fast',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});

View file

@ -0,0 +1,43 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
it('defaults connection_timeout to 10 seconds for new servers', function () {
expect($this->server->settings->connection_timeout)->toBe(10);
});
it('persists a custom connection_timeout value', function () {
$this->server->settings->connection_timeout = 30;
$this->server->settings->save();
expect($this->server->settings->fresh()->connection_timeout)->toBe(30);
});
it('returns the per-server connection_timeout from getConnectionTimeout', function () {
$this->server->settings->connection_timeout = 45;
$this->server->settings->save();
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45);
});
it('falls back to config default when connection_timeout is invalid', function () {
$this->server->settings->connection_timeout = 0;
$this->server->settings->saveQuietly();
$expected = (int) config('constants.ssh.connection_timeout');
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected);
});

View file

@ -0,0 +1,105 @@
<?php
use App\Events\ServerReachabilityChanged;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Channels\EmailChannel;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->team->emailNotificationSettings()->update([
'use_instance_email_settings' => true,
'server_unreachable_email_notifications' => true,
'server_reachable_email_notifications' => true,
]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
Notification::fake();
});
it('sends Unreachable notification when threshold reached and not yet notified', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 2,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertSentTo($this->team, Unreachable::class);
expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue();
});
it('does not send Unreachable on first transient failure (count=1)', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 1,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertNothingSent();
});
it('does not send Unreachable when already notified', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 5,
'unreachable_notification_sent' => true,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertNothingSent();
});
it('sends Reachable notification on recovery when previously notified', function () {
$this->server->settings()->update(['is_reachable' => true]);
$this->server->forceFill([
'unreachable_count' => 0,
'unreachable_notification_sent' => true,
])->save();
$fresh = $this->server->fresh();
expect($fresh->unreachable_notification_sent)->toBeTrue();
expect((bool) $fresh->settings->is_reachable)->toBeTrue();
ServerReachabilityChanged::dispatch($fresh);
Notification::assertSentTo($this->team, Reachable::class);
expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse();
});
it('does not send Reachable when never notified', function () {
$this->server->settings()->update(['is_reachable' => true]);
$this->server->forceFill([
'unreachable_count' => 0,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertNothingSent();
});
it('routes Unreachable notification through EmailChannel when email toggle is on', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 2,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) {
return in_array(EmailChannel::class, $channels);
});
});

View file

@ -0,0 +1,61 @@
<?php
use App\Models\Server;
afterEach(function () {
Mockery::close();
});
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
{
$settings = Mockery::mock();
$settings->is_reachable = $isReachable;
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('refresh')->andReturnSelf();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
return $server;
}
it('sends Reachable notification when reachable and notification was previously sent', function () {
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
$server->shouldReceive('sendReachableNotification')->once();
$server->shouldNotReceive('sendUnreachableNotification');
$server->isReachableChanged();
});
it('does not send any notification when reachable and notification was never sent', function () {
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
$server->shouldNotReceive('sendReachableNotification');
$server->shouldNotReceive('sendUnreachableNotification');
$server->isReachableChanged();
});
it('sends Unreachable notification when count >= 2 and not yet notified', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
$server->shouldReceive('sendUnreachableNotification')->once();
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
it('does not send Unreachable notification on first transient failure (count=1)', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
$server->shouldNotReceive('sendUnreachableNotification');
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
it('does not double-send Unreachable when already notified', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
$server->shouldNotReceive('sendUnreachableNotification');
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});

View file

@ -0,0 +1,66 @@
<?php
/**
* Unit tests for LocalFileVolume content size handling.
*
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
* mount large host files. Coolify previously stored full file content in the
* encrypted `content` mediumText column, then serialized it to the Livewire
* payload, crashing the browser.
*/
use App\Models\LocalFileVolume;
use Tests\TestCase;
uses(TestCase::class);
it('exposes a 5 MiB content size limit', function () {
expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
});
it('exposes binary and too-large placeholder constants', function () {
expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
});
it('flags is_too_large when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
expect($volume->is_too_large)->toBeTrue();
expect($volume->is_binary)->toBeFalse();
});
it('flags is_binary when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
expect($volume->is_binary)->toBeTrue();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag normal content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = "hello\nworld\n";
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag empty content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = null;
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('exposes the too-large flag via toArray for Livewire serialization', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
$array = $volume->toArray();
expect($array)->toHaveKey('is_too_large');
expect($array['is_too_large'])->toBeTrue();
});

View file

@ -1,11 +1,13 @@
<?php
use App\Events\ServerReachabilityChanged;
use App\Jobs\ServerCheckJob;
use App\Jobs\ServerConnectionCheckJob;
use App\Jobs\ServerManagerJob;
use App\Models\Server;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
uses(TestCase::class);
@ -126,16 +128,21 @@
describe('ServerConnectionCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
Event::fake([ServerReachabilityChanged::class]);
$settings = Mockery::mock();
$settings->is_reachable = true;
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
$job = new ServerConnectionCheckJob($server);
$job->failed(new TimeoutExceededException);
@ -152,6 +159,50 @@
});
});
describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
// ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
// call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
// through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
$invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
if ($expectDispatch) {
$server->shouldReceive('isReachableChanged')->once()->andReturnNull();
} else {
$server->shouldNotReceive('isReachableChanged');
}
$job = new ServerConnectionCheckJob($server);
$method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
$method->invoke($job, $wasReachable, $wasNotified, $isReachable);
};
it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
$invoke(true, false, false, 2, true);
});
it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
$invoke(true, false, false, 1, false);
});
it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
$invoke(false, true, false, 5, false);
});
it('dispatches recovery event when previously unreachable', function () use ($invoke) {
$invoke(false, false, true, 0, true);
});
it('dispatches recovery event when previously notified', function () use ($invoke) {
$invoke(true, true, true, 0, true);
});
it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
$invoke(true, false, true, 0, false);
});
});
describe('ServerCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();

View file

@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.0.0"
"version": "4.0.1"
},
"nightly": {
"version": "4.0.0"
@ -10,7 +10,7 @@
"version": "1.0.13"
},
"realtime": {
"version": "1.0.13"
"version": "1.0.14"
},
"sentinel": {
"version": "0.0.21"

View file

@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const viteHost = env.VITE_HOST || null;
const vitePort = Number(env.VITE_PORT || 5173);
return {
server: {
@ -14,9 +16,11 @@ export default defineConfig(({ mode }) => {
],
},
host: "0.0.0.0",
hmr: {
host: env.VITE_HOST || '0.0.0.0'
},
allowedHosts: true,
origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined,
hmr: viteHost
? { host: viteHost, clientPort: vitePort }
: true,
},
plugins: [
laravel({